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