@kybernesis/buzz 0.7.0 → 0.9.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
@@ -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,6 +1,10 @@
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
+ import { SessionStore } from "./sessions.js";
5
+ import { answerTurn, composeMessage, rejectedTurnReply } from "./turn.js";
6
+ import { existsSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
4
8
  import { Client } from "eve/client";
5
9
  import { BuzzRelay } from "./relay.js";
6
10
  import { loadKey, npubEncode } from "./keys.js";
@@ -48,8 +52,21 @@ export function buzzBridge(options) {
48
52
  * previous person's answer. The turn itself runs correctly, as the right
49
53
  * person, against the right connections; only the text posted back is
50
54
  * somebody else's, which is the most convincing way to look broken.
55
+ *
56
+ * Kept on DISK, not in memory. The eve session is durable; the knowledge of
57
+ * which session belongs to a channel was not, so a restart orphaned every
58
+ * conversation — the next message opened a new session and the agent said it
59
+ * had no context, while the old session sat intact holding a reply nobody was
60
+ * left to read.
51
61
  */
52
- const sessions = new Map();
62
+ const legacySessionsFile = ".buzz-sessions.json";
63
+ const sessionsFile = process.env.BUZZ_SESSIONS_FILE ??
64
+ (existsSync(legacySessionsFile)
65
+ ? legacySessionsFile
66
+ : join(dirname(options.keyFile), "buzz-sessions.json"));
67
+ const sessions = new SessionStore(sessionsFile, { onError: log });
68
+ if (sessions.size > 0)
69
+ log(`resumed ${sessions.size} conversation(s) from the last run`);
53
70
  /** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
54
71
  const invited = new Map();
55
72
  /**
@@ -104,107 +121,6 @@ export function buzzBridge(options) {
104
121
  * nothing. The bridge knew the answer the whole time: it is the connection
105
122
  * the message arrived on.
106
123
  */
107
- /**
108
- * One inbound message, as the parts a turn can carry.
109
- *
110
- * @remarks
111
- * Returns a plain string when there is nothing but text, so the common case
112
- * stays exactly as it was — a change in the shape of every ordinary turn is
113
- * not worth paying for the rare one.
114
- *
115
- * A failed fetch does NOT throw. The person is owed an answer either way, and
116
- * an agent told "there was an attachment I could not retrieve" can say so;
117
- * an agent told nothing answers the caption and looks like it ignored the
118
- * picture, which is the bug being fixed.
119
- */
120
- async function composeMessage(text, attachments) {
121
- if (attachments.length === 0)
122
- return text;
123
- const parts = [];
124
- if (text)
125
- parts.push({ type: "text", text });
126
- for (const ref of attachments) {
127
- try {
128
- const media = await fetchMedia(key, ref);
129
- if (isImage(media.mediaType)) {
130
- parts.push({ type: "image", image: media.bytes, mediaType: media.mediaType });
131
- }
132
- else {
133
- parts.push({
134
- type: "text",
135
- 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.]`,
136
- });
137
- }
138
- }
139
- catch (error) {
140
- log(`could not fetch an attachment: ${error.message}`);
141
- parts.push({
142
- type: "text",
143
- 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.]`,
144
- });
145
- }
146
- }
147
- return parts;
148
- }
149
- async function ask(channel, message, pubkey, community) {
150
- // Built per turn, for the person whose turn it is — and kept current for as
151
- // long as that turn runs.
152
- const client = clientFor(pubkey);
153
- const existing = sessions.get(channel);
154
- if (existing) {
155
- try {
156
- const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
157
- /**
158
- * Move to the true end of the conversation before speaking.
159
- *
160
- * `send()` opens its response stream at the position the handle already
161
- * holds and stops at the FIRST turn boundary it meets. If anything is
162
- * still unread there — the tail of a turn that outlived its reader, a
163
- * turn that was running when the bridge restarted — that boundary
164
- * belongs to the older turn. The read ends on it, this turn's answer is
165
- * never collected, and the stored position stays one turn behind.
166
- *
167
- * Which makes it permanent: every later turn reads the previous turn's
168
- * tail. The channel answers nothing, then answers a question from ten
169
- * minutes ago, and a fresh conversation elsewhere works perfectly —
170
- * because a fresh conversation has nothing left over to trip on.
171
- *
172
- * Draining first costs one bounded read of only what is unread, and it
173
- * repairs a position that has already drifted rather than requiring
174
- * anyone to notice. Non-following, so it ends at the tail instead of
175
- * waiting for the future.
176
- */
177
- let unread = 0;
178
- for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
179
- unread += 1;
180
- }
181
- if (unread > 0) {
182
- log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
183
- }
184
- const response = await session.send(message, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
185
- const result = await response.result();
186
- // Where this turn left the conversation, so the next one starts after it.
187
- sessions.set(channel, { id: existing.id, streamIndex: session.state.streamIndex });
188
- return result.message ?? "";
189
- }
190
- catch (error) {
191
- // A session the agent no longer holds cannot be resumed. Starting a new one loses the
192
- // thread but keeps the conversation alive, which is the better of the two failures.
193
- log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
194
- sessions.delete(channel);
195
- }
196
- }
197
- const created = await client.sessions.create({
198
- message,
199
- clientContext: { buzzCommunity: community, buzzChannel: channel },
200
- });
201
- const reply = (await created.response.result()).message ?? "";
202
- sessions.set(channel, {
203
- id: created.session.state.sessionId,
204
- streamIndex: created.session.state.streamIndex,
205
- });
206
- return reply;
207
- }
208
124
  /**
209
125
  * Send someone the link that makes them known, privately.
210
126
  *
@@ -310,11 +226,11 @@ export function buzzBridge(options) {
310
226
  * "I got a file I cannot read" is a worse answer than the truth only if
311
227
  * you never wanted the truth.
312
228
  */
313
- const message = await composeMessage(text, attachments);
229
+ const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
314
230
  void relay.react(event.id, SEEN);
315
231
  const stopTyping = relay.typingIn(channel);
316
232
  try {
317
- const reply = await serialize(channel, () => ask(channel, message, event.pubkey, from));
233
+ const reply = await serialize(channel, () => answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log));
318
234
  if (!reply) {
319
235
  /**
320
236
  * Silence is the worst answer available.
@@ -332,6 +248,18 @@ export function buzzBridge(options) {
332
248
  log(`replied (${reply.length} chars)`);
333
249
  }
334
250
  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
+ */
257
+ const rejected = rejectedTurnReply(error);
258
+ if (rejected) {
259
+ log(`turn rejected for ${channel.slice(0, 8)}: ${error.message}`);
260
+ relay.reply(channel, rejected, event);
261
+ return;
262
+ }
335
263
  log(`failed to answer: ${error.message}`);
336
264
  }
337
265
  finally {
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/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;
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;
@@ -98,6 +101,11 @@ export class BuzzRelay {
98
101
  clearInterval(this.presenceTimer);
99
102
  if (this.pollTimer)
100
103
  clearTimeout(this.pollTimer);
104
+ if (this.reconnectTimer)
105
+ clearTimeout(this.reconnectTimer);
106
+ this.presenceTimer = null;
107
+ this.pollTimer = null;
108
+ this.reconnectTimer = null;
101
109
  try {
102
110
  this.socket?.close();
103
111
  }
@@ -108,6 +116,14 @@ export class BuzzRelay {
108
116
  reconnect() {
109
117
  if (this.closed)
110
118
  return;
119
+ if (this.presenceTimer)
120
+ clearInterval(this.presenceTimer);
121
+ if (this.pollTimer)
122
+ clearTimeout(this.pollTimer);
123
+ this.presenceTimer = null;
124
+ this.pollTimer = null;
125
+ if (this.reconnectTimer)
126
+ return;
111
127
  // Backs off rather than hammering. A membership refusal is the common case
112
128
  // here — an agent whose invite has not happened yet — and it is not
113
129
  // something a retry a second from now can fix, though a retry eventually
@@ -115,7 +131,10 @@ export class BuzzRelay {
115
131
  const wait = this.backoff;
116
132
  this.backoff = Math.min(this.backoff * 2, 60_000);
117
133
  this.log(`disconnected; reconnecting in ${Math.round(wait / 1000)}s`);
118
- setTimeout(() => this.connect(), wait);
134
+ this.reconnectTimer = setTimeout(() => {
135
+ this.reconnectTimer = null;
136
+ this.connect();
137
+ }, wait);
119
138
  }
120
139
  receive(data) {
121
140
  let frame;
@@ -177,8 +196,16 @@ export class BuzzRelay {
177
196
  if (this.seen.has(event.id))
178
197
  return;
179
198
  this.seen.add(event.id);
180
- if (event.created_at >= this.cursor)
181
- this.cursor = event.created_at + 1;
199
+ if (this.seen.size > MAX_SEEN_EVENT_IDS) {
200
+ const oldest = this.seen.values().next().value;
201
+ if (oldest)
202
+ this.seen.delete(oldest);
203
+ }
204
+ // Nostr timestamps have one-second precision and `since` is inclusive. Keep
205
+ // the latest second in the next poll; the seen set removes redelivery while
206
+ // allowing another event created during that same second to arrive.
207
+ if (event.created_at > this.cursor)
208
+ this.cursor = event.created_at;
182
209
  this.onMessage(event);
183
210
  }
184
211
  send(frame) {
@@ -0,0 +1,39 @@
1
+ /** One conversation: which eve session it is, and how far the bridge has read. */
2
+ export interface StoredSession {
3
+ id: string;
4
+ streamIndex: number;
5
+ /** When it was last used, so a store that runs for years does not grow forever. */
6
+ updated: number;
7
+ }
8
+ /**
9
+ * Which eve session belongs to which channel, across restarts.
10
+ *
11
+ * @remarks
12
+ * The eve session itself is durable — it lives on the agent's disk and survives
13
+ * anything. What did NOT survive was the bridge's knowledge of WHICH session
14
+ * belonged to a channel: that was a Map in memory, so a restart silently
15
+ * orphaned every conversation. The next message opened a new session and the
16
+ * agent answered "I have no context", while the old session sat on disk intact,
17
+ * holding a turn whose reply nobody was left to read.
18
+ *
19
+ * Reported from a live deployment after a restart mid-conversation, and the
20
+ * diagnosis there was exactly right: durable on one side, ephemeral on the
21
+ * other, and the join between them was the part that could not survive.
22
+ *
23
+ * Keyed by community AND channel: channel ids are issued per relay, so an agent
24
+ * in two workspaces cannot assume they never collide.
25
+ */
26
+ export declare class SessionStore {
27
+ #private;
28
+ constructor(file: string, options?: {
29
+ onError?: (message: string) => void;
30
+ });
31
+ static key(community: string, channel: string): string;
32
+ get(community: string, channel: string): StoredSession | undefined;
33
+ set(community: string, channel: string, session: {
34
+ id: string;
35
+ streamIndex: number;
36
+ }): void;
37
+ delete(community: string, channel: string): void;
38
+ get size(): number;
39
+ }
@@ -0,0 +1,84 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ /** How long an untouched conversation is kept before it is forgotten. */
4
+ const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
5
+ /**
6
+ * Which eve session belongs to which channel, across restarts.
7
+ *
8
+ * @remarks
9
+ * The eve session itself is durable — it lives on the agent's disk and survives
10
+ * anything. What did NOT survive was the bridge's knowledge of WHICH session
11
+ * belonged to a channel: that was a Map in memory, so a restart silently
12
+ * orphaned every conversation. The next message opened a new session and the
13
+ * agent answered "I have no context", while the old session sat on disk intact,
14
+ * holding a turn whose reply nobody was left to read.
15
+ *
16
+ * Reported from a live deployment after a restart mid-conversation, and the
17
+ * diagnosis there was exactly right: durable on one side, ephemeral on the
18
+ * other, and the join between them was the part that could not survive.
19
+ *
20
+ * Keyed by community AND channel: channel ids are issued per relay, so an agent
21
+ * in two workspaces cannot assume they never collide.
22
+ */
23
+ export class SessionStore {
24
+ #file;
25
+ #entries = new Map();
26
+ #onError;
27
+ constructor(file, options = {}) {
28
+ this.#file = file;
29
+ this.#onError = options.onError ?? (() => { });
30
+ this.#load();
31
+ }
32
+ static key(community, channel) {
33
+ return `${community}|${channel}`;
34
+ }
35
+ get(community, channel) {
36
+ return this.#entries.get(SessionStore.key(community, channel));
37
+ }
38
+ set(community, channel, session) {
39
+ this.#entries.set(SessionStore.key(community, channel), { ...session, updated: Date.now() });
40
+ this.#save();
41
+ }
42
+ delete(community, channel) {
43
+ this.#entries.delete(SessionStore.key(community, channel));
44
+ this.#save();
45
+ }
46
+ get size() {
47
+ return this.#entries.size;
48
+ }
49
+ #load() {
50
+ if (!existsSync(this.#file))
51
+ return;
52
+ try {
53
+ const raw = JSON.parse(readFileSync(this.#file, "utf8"));
54
+ const cutoff = Date.now() - KEEP_MS;
55
+ for (const [key, value] of Object.entries(raw)) {
56
+ if (typeof value?.id === "string" && (value.updated ?? 0) > cutoff) {
57
+ this.#entries.set(key, value);
58
+ }
59
+ }
60
+ }
61
+ catch (error) {
62
+ // A corrupt store is not worth refusing to start over. Losing the mapping
63
+ // costs conversation history; refusing to start costs the whole agent.
64
+ this.#onError(`could not load Buzz session store ${this.#file}: ${error.message}`);
65
+ }
66
+ }
67
+ #save() {
68
+ try {
69
+ mkdirSync(dirname(this.#file), { recursive: true });
70
+ // Written beside the target and renamed, because the failure being
71
+ // guarded against is a restart — and a restart during a plain write
72
+ // leaves a truncated file that reads as "no sessions at all", which is
73
+ // the exact bug this class exists to prevent.
74
+ const tmp = `${this.#file}.tmp`;
75
+ writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.#entries)), { mode: 0o600 });
76
+ renameSync(tmp, this.#file);
77
+ }
78
+ catch (error) {
79
+ // Best effort: an agent that cannot write its store still answers, it
80
+ // just forgets across restarts — which is where this started.
81
+ this.#onError(`could not write Buzz session store ${this.#file}: ${error.message}`);
82
+ }
83
+ }
84
+ }
package/dist/turn.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { type Client, 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
+ /** Convert one inbound Buzz message into an eve-compatible turn. */
7
+ export declare function composeMessage(text: string, attachments: MediaRef[], fetchAttachment: (ref: MediaRef) => Promise<FetchedMedia>, log?: (message: string) => void): Promise<TurnMessage>;
8
+ /** 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>;
10
+ /**
11
+ * What the room is told when eve refuses the turn itself.
12
+ *
13
+ * A 400 means the request was malformed — a part eve does not accept, a body
14
+ * it could not parse — not that the agent failed to think of an answer. The
15
+ * session survives it (`answerTurn` keeps the mapping), but the person who
16
+ * sent the message is still waiting, and a log line nobody is reading is not
17
+ * an answer. Returns `null` for anything that is not a request rejection, so
18
+ * the caller's ordinary failure path stays as it was.
19
+ */
20
+ export declare function rejectedTurnReply(error: unknown): string | null;
package/dist/turn.js ADDED
@@ -0,0 +1,117 @@
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
+ /** Continue a channel's existing conversation, replacing it only when it is stale. */
37
+ export async function answerTurn(client, sessions, channel, message, community, log = () => { }) {
38
+ const existing = sessions.get(community, channel);
39
+ if (existing) {
40
+ try {
41
+ const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
42
+ /**
43
+ * Move to the true end of the conversation before speaking.
44
+ *
45
+ * `send()` opens its response stream at the position the handle already
46
+ * holds and stops at the FIRST turn boundary it meets. If anything is
47
+ * still unread there — the tail of a turn that outlived its reader, a
48
+ * turn that was running when the bridge restarted — that boundary
49
+ * belongs to the older turn. The read ends on it, this turn's answer is
50
+ * never collected, and the stored position stays one turn behind.
51
+ *
52
+ * Which makes it permanent: every later turn reads the previous turn's
53
+ * tail. The channel answers nothing, then answers a question from ten
54
+ * minutes ago, and a fresh conversation elsewhere works perfectly —
55
+ * because a fresh conversation has nothing left over to trip on.
56
+ *
57
+ * Draining first costs one bounded read of only what is unread, and it
58
+ * repairs a position that has already drifted rather than requiring
59
+ * anyone to notice. Non-following, so it ends at the tail instead of
60
+ * waiting for the future.
61
+ */
62
+ let unread = 0;
63
+ for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
64
+ unread += 1;
65
+ }
66
+ if (unread > 0) {
67
+ log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
68
+ }
69
+ const response = await session.send(message, {
70
+ clientContext: { buzzCommunity: community, buzzChannel: channel },
71
+ });
72
+ const result = await response.result();
73
+ sessions.set(community, channel, {
74
+ id: existing.id,
75
+ streamIndex: session.state.streamIndex,
76
+ });
77
+ return result.message ?? "";
78
+ }
79
+ catch (error) {
80
+ // A malformed turn does not make its durable session stale. Rethrow so
81
+ // the bridge can tell the room (see `rejectedTurnReply`), and keep the
82
+ // mapping so the next valid message continues the same conversation.
83
+ if (error instanceof ClientError && error.status === 400)
84
+ throw error;
85
+ log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
86
+ sessions.delete(community, channel);
87
+ }
88
+ }
89
+ const created = await client.sessions.create({
90
+ message,
91
+ clientContext: { buzzCommunity: community, buzzChannel: channel },
92
+ });
93
+ const reply = (await created.response.result()).message ?? "";
94
+ sessions.set(community, channel, {
95
+ id: created.session.state.sessionId,
96
+ streamIndex: created.session.state.streamIndex,
97
+ });
98
+ return reply;
99
+ }
100
+ /**
101
+ * What the room is told when eve refuses the turn itself.
102
+ *
103
+ * A 400 means the request was malformed — a part eve does not accept, a body
104
+ * it could not parse — not that the agent failed to think of an answer. The
105
+ * session survives it (`answerTurn` keeps the mapping), but the person who
106
+ * sent the message is still waiting, and a log line nobody is reading is not
107
+ * an answer. Returns `null` for anything that is not a request rejection, so
108
+ * the caller's ordinary failure path stays as it was.
109
+ */
110
+ export function rejectedTurnReply(error) {
111
+ if (!(error instanceof ClientError) || error.status !== 400)
112
+ return null;
113
+ const detail = String(error.message ?? "").split("\n")[0].trim().slice(0, 160);
114
+ return detail
115
+ ? `I couldn't read that message (${detail}). Try sending it again, or as plain text.`
116
+ : "I couldn't read that message. Try sending it again, or as plain text.";
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.7.0",
3
+ "version": "0.9.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,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"