@kybernesis/buzz 0.8.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 +1 -0
- package/dist/bridge.js +24 -105
- package/dist/cli.js +1 -0
- package/dist/extension/_manifest.json +3 -3
- package/dist/relay.d.ts +3 -0
- package/dist/relay.js +30 -3
- package/dist/sessions.d.ts +3 -1
- package/dist/sessions.js +7 -3
- package/dist/turn.d.ts +20 -0
- package/dist/turn.js +117 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ sessions are keyed by channel.
|
|
|
60
60
|
| `BUZZ_RELAY` | The workspace relay (`wss://…`) — or several, comma-separated |
|
|
61
61
|
| `BUZZ_AGENT_URL` | Where the agent listens (default `http://127.0.0.1:8000`) |
|
|
62
62
|
| `BUZZ_KEYFILE` | The agent's key (default `~/.kybernesis/buzz-agent.json`) |
|
|
63
|
+
| `BUZZ_SESSIONS_FILE` | Channel-to-session continuity store (default beside the key file; an existing legacy `.buzz-sessions.json` is reused) |
|
|
63
64
|
| `KYBERNESIS_ISSUER` | The control plane |
|
|
64
65
|
| `KYBERNESIS_AGENT_CREDENTIAL` | This agent's credential |
|
|
65
66
|
|
package/dist/bridge.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { channelIdentity } from "@kybernesis/enterprise";
|
|
2
|
-
import { fetchMedia,
|
|
2
|
+
import { fetchMedia, parseMedia } from "./media.js";
|
|
3
3
|
import { speakerCredentials } from "./credentials.js";
|
|
4
4
|
import { SessionStore } from "./sessions.js";
|
|
5
|
+
import { answerTurn, composeMessage, rejectedTurnReply } from "./turn.js";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
5
8
|
import { Client } from "eve/client";
|
|
6
9
|
import { BuzzRelay } from "./relay.js";
|
|
7
10
|
import { loadKey, npubEncode } from "./keys.js";
|
|
@@ -56,7 +59,12 @@ export function buzzBridge(options) {
|
|
|
56
59
|
* had no context, while the old session sat intact holding a reply nobody was
|
|
57
60
|
* left to read.
|
|
58
61
|
*/
|
|
59
|
-
const
|
|
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 });
|
|
60
68
|
if (sessions.size > 0)
|
|
61
69
|
log(`resumed ${sessions.size} conversation(s) from the last run`);
|
|
62
70
|
/** Who has already been sent a link, so a room full of strangers is not a room full of spam. */
|
|
@@ -113,107 +121,6 @@ export function buzzBridge(options) {
|
|
|
113
121
|
* nothing. The bridge knew the answer the whole time: it is the connection
|
|
114
122
|
* the message arrived on.
|
|
115
123
|
*/
|
|
116
|
-
/**
|
|
117
|
-
* One inbound message, as the parts a turn can carry.
|
|
118
|
-
*
|
|
119
|
-
* @remarks
|
|
120
|
-
* Returns a plain string when there is nothing but text, so the common case
|
|
121
|
-
* stays exactly as it was — a change in the shape of every ordinary turn is
|
|
122
|
-
* not worth paying for the rare one.
|
|
123
|
-
*
|
|
124
|
-
* A failed fetch does NOT throw. The person is owed an answer either way, and
|
|
125
|
-
* an agent told "there was an attachment I could not retrieve" can say so;
|
|
126
|
-
* an agent told nothing answers the caption and looks like it ignored the
|
|
127
|
-
* picture, which is the bug being fixed.
|
|
128
|
-
*/
|
|
129
|
-
async function composeMessage(text, attachments) {
|
|
130
|
-
if (attachments.length === 0)
|
|
131
|
-
return text;
|
|
132
|
-
const parts = [];
|
|
133
|
-
if (text)
|
|
134
|
-
parts.push({ type: "text", text });
|
|
135
|
-
for (const ref of attachments) {
|
|
136
|
-
try {
|
|
137
|
-
const media = await fetchMedia(key, ref);
|
|
138
|
-
if (isImage(media.mediaType)) {
|
|
139
|
-
parts.push({ type: "image", image: media.bytes, mediaType: media.mediaType });
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
parts.push({
|
|
143
|
-
type: "text",
|
|
144
|
-
text: `[The sender attached a ${media.mediaType} file, which cannot be read as an image. Tell them what you received and ask for it in a readable form if you need it.]`,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
log(`could not fetch an attachment: ${error.message}`);
|
|
150
|
-
parts.push({
|
|
151
|
-
type: "text",
|
|
152
|
-
text: `[The sender attached a file, and it could not be retrieved (${error.message}). Say so rather than answering as if there were no attachment.]`,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return parts;
|
|
157
|
-
}
|
|
158
|
-
async function ask(channel, message, pubkey, community) {
|
|
159
|
-
// Built per turn, for the person whose turn it is — and kept current for as
|
|
160
|
-
// long as that turn runs.
|
|
161
|
-
const client = clientFor(pubkey);
|
|
162
|
-
const existing = sessions.get(community, channel);
|
|
163
|
-
if (existing) {
|
|
164
|
-
try {
|
|
165
|
-
const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
|
|
166
|
-
/**
|
|
167
|
-
* Move to the true end of the conversation before speaking.
|
|
168
|
-
*
|
|
169
|
-
* `send()` opens its response stream at the position the handle already
|
|
170
|
-
* holds and stops at the FIRST turn boundary it meets. If anything is
|
|
171
|
-
* still unread there — the tail of a turn that outlived its reader, a
|
|
172
|
-
* turn that was running when the bridge restarted — that boundary
|
|
173
|
-
* belongs to the older turn. The read ends on it, this turn's answer is
|
|
174
|
-
* never collected, and the stored position stays one turn behind.
|
|
175
|
-
*
|
|
176
|
-
* Which makes it permanent: every later turn reads the previous turn's
|
|
177
|
-
* tail. The channel answers nothing, then answers a question from ten
|
|
178
|
-
* minutes ago, and a fresh conversation elsewhere works perfectly —
|
|
179
|
-
* because a fresh conversation has nothing left over to trip on.
|
|
180
|
-
*
|
|
181
|
-
* Draining first costs one bounded read of only what is unread, and it
|
|
182
|
-
* repairs a position that has already drifted rather than requiring
|
|
183
|
-
* anyone to notice. Non-following, so it ends at the tail instead of
|
|
184
|
-
* waiting for the future.
|
|
185
|
-
*/
|
|
186
|
-
let unread = 0;
|
|
187
|
-
for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
|
|
188
|
-
unread += 1;
|
|
189
|
-
}
|
|
190
|
-
if (unread > 0) {
|
|
191
|
-
log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
|
|
192
|
-
}
|
|
193
|
-
const response = await session.send(message, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
|
|
194
|
-
const result = await response.result();
|
|
195
|
-
// Where this turn left the conversation, so the next one starts after it.
|
|
196
|
-
sessions.set(community, channel, { id: existing.id, streamIndex: session.state.streamIndex });
|
|
197
|
-
return result.message ?? "";
|
|
198
|
-
}
|
|
199
|
-
catch (error) {
|
|
200
|
-
// A session the agent no longer holds cannot be resumed. Starting a new one loses the
|
|
201
|
-
// thread but keeps the conversation alive, which is the better of the two failures.
|
|
202
|
-
log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
|
|
203
|
-
sessions.delete(community, channel);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
const created = await client.sessions.create({
|
|
207
|
-
message,
|
|
208
|
-
clientContext: { buzzCommunity: community, buzzChannel: channel },
|
|
209
|
-
});
|
|
210
|
-
const reply = (await created.response.result()).message ?? "";
|
|
211
|
-
sessions.set(community, channel, {
|
|
212
|
-
id: created.session.state.sessionId,
|
|
213
|
-
streamIndex: created.session.state.streamIndex,
|
|
214
|
-
});
|
|
215
|
-
return reply;
|
|
216
|
-
}
|
|
217
124
|
/**
|
|
218
125
|
* Send someone the link that makes them known, privately.
|
|
219
126
|
*
|
|
@@ -319,11 +226,11 @@ export function buzzBridge(options) {
|
|
|
319
226
|
* "I got a file I cannot read" is a worse answer than the truth only if
|
|
320
227
|
* you never wanted the truth.
|
|
321
228
|
*/
|
|
322
|
-
const message = await composeMessage(text, attachments);
|
|
229
|
+
const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
|
|
323
230
|
void relay.react(event.id, SEEN);
|
|
324
231
|
const stopTyping = relay.typingIn(channel);
|
|
325
232
|
try {
|
|
326
|
-
const reply = await serialize(channel, () =>
|
|
233
|
+
const reply = await serialize(channel, () => answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log));
|
|
327
234
|
if (!reply) {
|
|
328
235
|
/**
|
|
329
236
|
* Silence is the worst answer available.
|
|
@@ -341,6 +248,18 @@ export function buzzBridge(options) {
|
|
|
341
248
|
log(`replied (${reply.length} chars)`);
|
|
342
249
|
}
|
|
343
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
|
+
}
|
|
344
263
|
log(`failed to answer: ${error.message}`);
|
|
345
264
|
}
|
|
346
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
|
`);
|
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(() =>
|
|
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 (
|
|
181
|
-
|
|
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) {
|
package/dist/sessions.d.ts
CHANGED
|
@@ -25,7 +25,9 @@ export interface StoredSession {
|
|
|
25
25
|
*/
|
|
26
26
|
export declare class SessionStore {
|
|
27
27
|
#private;
|
|
28
|
-
constructor(file: string
|
|
28
|
+
constructor(file: string, options?: {
|
|
29
|
+
onError?: (message: string) => void;
|
|
30
|
+
});
|
|
29
31
|
static key(community: string, channel: string): string;
|
|
30
32
|
get(community: string, channel: string): StoredSession | undefined;
|
|
31
33
|
set(community: string, channel: string, session: {
|
package/dist/sessions.js
CHANGED
|
@@ -23,8 +23,10 @@ const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
23
23
|
export class SessionStore {
|
|
24
24
|
#file;
|
|
25
25
|
#entries = new Map();
|
|
26
|
-
|
|
26
|
+
#onError;
|
|
27
|
+
constructor(file, options = {}) {
|
|
27
28
|
this.#file = file;
|
|
29
|
+
this.#onError = options.onError ?? (() => { });
|
|
28
30
|
this.#load();
|
|
29
31
|
}
|
|
30
32
|
static key(community, channel) {
|
|
@@ -56,9 +58,10 @@ export class SessionStore {
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
|
-
catch {
|
|
61
|
+
catch (error) {
|
|
60
62
|
// A corrupt store is not worth refusing to start over. Losing the mapping
|
|
61
63
|
// costs conversation history; refusing to start costs the whole agent.
|
|
64
|
+
this.#onError(`could not load Buzz session store ${this.#file}: ${error.message}`);
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
#save() {
|
|
@@ -72,9 +75,10 @@ export class SessionStore {
|
|
|
72
75
|
writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.#entries)), { mode: 0o600 });
|
|
73
76
|
renameSync(tmp, this.#file);
|
|
74
77
|
}
|
|
75
|
-
catch {
|
|
78
|
+
catch (error) {
|
|
76
79
|
// Best effort: an agent that cannot write its store still answers, it
|
|
77
80
|
// just forgets across restarts — which is where this started.
|
|
81
|
+
this.#onError(`could not write Buzz session store ${this.#file}: ${error.message}`);
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
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.
|
|
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.
|
|
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.
|
|
53
|
+
"eve": "0.49.0",
|
|
54
54
|
"typescript": "7.0.2"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"eve": ">=0.
|
|
57
|
+
"eve": ">=0.49.0 <0.50.0"
|
|
58
58
|
},
|
|
59
59
|
"engines": {
|
|
60
60
|
"node": ">=20"
|