@kybernesis/buzz 0.9.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/dist/bridge.js +190 -51
- package/dist/hitl.d.ts +34 -0
- package/dist/hitl.js +148 -0
- package/dist/relay.d.ts +9 -3
- package/dist/relay.js +24 -4
- package/dist/sessions.d.ts +21 -5
- package/dist/sessions.js +19 -2
- package/dist/turn.d.ts +10 -2
- package/dist/turn.js +15 -3
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fetchMedia, parseMedia } from "./media.js";
|
|
|
3
3
|
import { speakerCredentials } from "./credentials.js";
|
|
4
4
|
import { SessionStore } from "./sessions.js";
|
|
5
5
|
import { answerTurn, composeMessage, rejectedTurnReply } from "./turn.js";
|
|
6
|
+
import { followPendingConversation, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, } from "./hitl.js";
|
|
6
7
|
import { existsSync } from "node:fs";
|
|
7
8
|
import { dirname, join } from "node:path";
|
|
8
9
|
import { Client } from "eve/client";
|
|
@@ -105,10 +106,11 @@ export function buzzBridge(options) {
|
|
|
105
106
|
* turns are ordered whether or not the people in it take turns.
|
|
106
107
|
*/
|
|
107
108
|
const inFlight = new Map();
|
|
108
|
-
function serialize(channel, work) {
|
|
109
|
-
const
|
|
109
|
+
function serialize(community, channel, work) {
|
|
110
|
+
const key = SessionStore.key(community, channel);
|
|
111
|
+
const queued = (inFlight.get(key) ?? Promise.resolve()).then(work, work);
|
|
110
112
|
// Kept only while it matters: the chain holds the LAST promise, not a history.
|
|
111
|
-
inFlight.set(
|
|
113
|
+
inFlight.set(key, queued.catch(() => { }));
|
|
112
114
|
return queued;
|
|
113
115
|
}
|
|
114
116
|
/**
|
|
@@ -171,6 +173,97 @@ export function buzzBridge(options) {
|
|
|
171
173
|
},
|
|
172
174
|
}));
|
|
173
175
|
}
|
|
176
|
+
/** One abortable durable-stream follower owns each parked conversation. */
|
|
177
|
+
const followers = new Map();
|
|
178
|
+
const followerRestartTimers = new Map();
|
|
179
|
+
let stopped = false;
|
|
180
|
+
function clearFollowerRestart(conversation) {
|
|
181
|
+
const timer = followerRestartTimers.get(conversation);
|
|
182
|
+
if (timer)
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
followerRestartTimers.delete(conversation);
|
|
185
|
+
}
|
|
186
|
+
function stopFollower(community, channel) {
|
|
187
|
+
const conversation = SessionStore.key(community, channel);
|
|
188
|
+
clearFollowerRestart(conversation);
|
|
189
|
+
followers.get(conversation)?.abort();
|
|
190
|
+
followers.delete(conversation);
|
|
191
|
+
}
|
|
192
|
+
function startFollower(community, channel) {
|
|
193
|
+
if (stopped)
|
|
194
|
+
return;
|
|
195
|
+
const conversation = SessionStore.key(community, channel);
|
|
196
|
+
clearFollowerRestart(conversation);
|
|
197
|
+
if (followers.has(conversation))
|
|
198
|
+
return;
|
|
199
|
+
const stored = sessions.get(community, channel);
|
|
200
|
+
const relay = relays.get(community);
|
|
201
|
+
if (!stored || !needsPendingFollower(stored) || !stored.speakerPublicKey || !relay)
|
|
202
|
+
return;
|
|
203
|
+
const controller = new AbortController();
|
|
204
|
+
followers.set(conversation, controller);
|
|
205
|
+
const session = clientFor(stored.speakerPublicKey).sessions.attach(stored.id, {
|
|
206
|
+
streamIndex: stored.streamIndex,
|
|
207
|
+
});
|
|
208
|
+
void followPendingConversation(session, stored, {
|
|
209
|
+
// A stopped/replaced follower may already have queued a callback. Check
|
|
210
|
+
// its generation inside the same conversation queue used by inbound
|
|
211
|
+
// messages so stale state cannot overwrite a newer responder or cursor.
|
|
212
|
+
onState: (state) => serialize(community, channel, async () => {
|
|
213
|
+
if (followers.get(conversation) !== controller)
|
|
214
|
+
return;
|
|
215
|
+
const current = sessions.get(community, channel);
|
|
216
|
+
if (state.pendingInputRequests?.length) {
|
|
217
|
+
sessions.set(community, channel, { ...state, promptEventIds: current?.promptEventIds });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (current?.promptEventIds?.length)
|
|
221
|
+
relay.unwatchReplies(current.promptEventIds);
|
|
222
|
+
sessions.set(community, channel, state);
|
|
223
|
+
}),
|
|
224
|
+
onInputRequested: (requests) => serialize(community, channel, async () => {
|
|
225
|
+
if (followers.get(conversation) !== controller)
|
|
226
|
+
return;
|
|
227
|
+
const promptId = relay.reply(channel, formatInputRequests(requests));
|
|
228
|
+
if (!promptId)
|
|
229
|
+
throw new Error("Buzz relay is not connected");
|
|
230
|
+
relay.watchReplies([promptId]);
|
|
231
|
+
const current = sessions.get(community, channel);
|
|
232
|
+
if (current) {
|
|
233
|
+
sessions.set(community, channel, {
|
|
234
|
+
...current,
|
|
235
|
+
promptEventIds: [...(current.promptEventIds ?? []), promptId],
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
log(`posted another input request in ${channel.slice(0, 8)}`);
|
|
239
|
+
}),
|
|
240
|
+
onMessage: (message) => serialize(community, channel, async () => {
|
|
241
|
+
if (followers.get(conversation) !== controller)
|
|
242
|
+
return;
|
|
243
|
+
if (!relay.reply(channel, message))
|
|
244
|
+
throw new Error("Buzz relay is not connected");
|
|
245
|
+
log(`posted resumed reply in ${channel.slice(0, 8)} (${message.length} chars)`);
|
|
246
|
+
}),
|
|
247
|
+
onLog: log,
|
|
248
|
+
}, controller.signal).catch((error) => {
|
|
249
|
+
if (!controller.signal.aborted) {
|
|
250
|
+
// Keep the persisted request, cursor and public identity. A later reply
|
|
251
|
+
// or process restart can refresh credentials and reattach.
|
|
252
|
+
log(`pending follower for ${channel.slice(0, 8)} disconnected (${error.message})`);
|
|
253
|
+
}
|
|
254
|
+
}).finally(() => {
|
|
255
|
+
if (followers.get(conversation) === controller)
|
|
256
|
+
followers.delete(conversation);
|
|
257
|
+
const recoverable = sessions.get(community, channel);
|
|
258
|
+
if (!controller.signal.aborted && recoverable && needsPendingFollower(recoverable)) {
|
|
259
|
+
const timer = setTimeout(() => {
|
|
260
|
+
followerRestartTimers.delete(conversation);
|
|
261
|
+
startFollower(community, channel);
|
|
262
|
+
}, 1_000);
|
|
263
|
+
followerRestartTimers.set(conversation, timer);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
174
267
|
async function handle(event, from) {
|
|
175
268
|
const relay = relays.get(from);
|
|
176
269
|
if (!relay)
|
|
@@ -179,24 +272,15 @@ export function buzzBridge(options) {
|
|
|
179
272
|
// Addressed by TAG, not by text. A name in prose is a string anyone can type; a p tag is what
|
|
180
273
|
// the client emits when someone actually picks this member out of a mention list.
|
|
181
274
|
const addressed = event.tags.some((t) => t[0] === "p" && t[1] === key.publicKey);
|
|
182
|
-
|
|
275
|
+
// A reply to a prompt this agent posted is an answer even when it names nobody: people answer
|
|
276
|
+
// a question by replying to it, not by mentioning the asker. Only while that prompt is open.
|
|
277
|
+
const repliedTo = event.tags.find((t) => t[0] === "e")?.[1];
|
|
278
|
+
const open = channel ? sessions.get(from, channel) : undefined;
|
|
279
|
+
const answersPrompt = Boolean(repliedTo && open?.pendingInputRequests?.length && open.promptEventIds?.includes(repliedTo));
|
|
280
|
+
if (!channel || !(addressed || answersPrompt))
|
|
183
281
|
return;
|
|
184
282
|
const text = String(event.content ?? "").trim();
|
|
185
283
|
const attachments = parseMedia(event);
|
|
186
|
-
/**
|
|
187
|
-
* Drop only a message with NOTHING in it — and say so when we do.
|
|
188
|
-
*
|
|
189
|
-
* This guard used to test the text alone, and sat above the log line, so an
|
|
190
|
-
* image sent without a caption was discarded before anything was written
|
|
191
|
-
* down. From the outside the agent had simply ignored someone; from the
|
|
192
|
-
* inside there was no record that a message had ever arrived. The reply
|
|
193
|
-
* path was hardened against exactly this failure — an empty answer now
|
|
194
|
-
* says so out loud — and the receiving path was left as it was.
|
|
195
|
-
*/
|
|
196
|
-
if (!text && attachments.length === 0) {
|
|
197
|
-
log(`nothing to answer in ${channel.slice(0, 8)} from ${event.pubkey.slice(0, 8)} — no text, no attachments`);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
284
|
let speaker;
|
|
201
285
|
try {
|
|
202
286
|
speaker = await identity.resolve(PROVIDER, event.pubkey, npubEncode(event.pubkey));
|
|
@@ -216,44 +300,83 @@ export function buzzBridge(options) {
|
|
|
216
300
|
}
|
|
217
301
|
log(`asked in ${channel.slice(0, 8)} by ${speaker.user.email}: ${text.slice(0, 60)}` +
|
|
218
302
|
(attachments.length ? ` [+${attachments.length} attachment(s)]` : ""));
|
|
219
|
-
/**
|
|
220
|
-
* Attachments become parts of the turn, or become words about themselves.
|
|
221
|
-
*
|
|
222
|
-
* An image the model can see goes in as bytes. Anything else — a zip, a
|
|
223
|
-
* PDF the model cannot read, a fetch that failed — goes in as a sentence
|
|
224
|
-
* saying so, because the alternative is an agent that answers the caption
|
|
225
|
-
* while the person waits for a reply about the file they sent. Being told
|
|
226
|
-
* "I got a file I cannot read" is a worse answer than the truth only if
|
|
227
|
-
* you never wanted the truth.
|
|
228
|
-
*/
|
|
229
|
-
const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
|
|
230
303
|
void relay.react(event.id, SEEN);
|
|
231
304
|
const stopTyping = relay.typingIn(channel);
|
|
232
305
|
try {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
306
|
+
await serialize(from, channel, async () => {
|
|
307
|
+
const pending = sessions.get(from, channel);
|
|
308
|
+
if (pending?.pendingInputRequests?.length) {
|
|
309
|
+
// "@Ava approve" is an approve. The mention is how the message reached us, not the answer.
|
|
310
|
+
const answer = addressed ? text.replace(/^@\S+\s*/, "") : text;
|
|
311
|
+
const responses = resolveInputReply(answer, pending.pendingInputRequests);
|
|
312
|
+
if (!responses) {
|
|
313
|
+
relay.reply(channel, invalidInputReply(pending.pendingInputRequests), event);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Reattach both POST and follower with the current responder's freshly
|
|
317
|
+
// resolved credentials. The follower remains the sole output reader.
|
|
318
|
+
stopFollower(from, channel);
|
|
319
|
+
sessions.set(from, channel, {
|
|
320
|
+
...pending,
|
|
321
|
+
speakerPublicKey: event.pubkey,
|
|
322
|
+
});
|
|
323
|
+
startFollower(from, channel);
|
|
324
|
+
const session = clientFor(event.pubkey).sessions.attach(pending.id, {
|
|
325
|
+
streamIndex: pending.streamIndex,
|
|
326
|
+
});
|
|
327
|
+
await respondToPendingConversation(session, responses, from, channel);
|
|
328
|
+
log(`submitted ${responses.length} input response(s) in ${channel.slice(0, 8)}`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (pending?.resumeInFlight) {
|
|
332
|
+
startFollower(from, channel);
|
|
333
|
+
relay.reply(channel, "I'm still finishing the resumed request. I'll post the answer here when it is ready.", event);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
/** Drop only a message with NOTHING in it — pending HITL replies are handled above. */
|
|
337
|
+
if (!text && attachments.length === 0) {
|
|
338
|
+
log(`nothing to answer in ${channel.slice(0, 8)} from ${event.pubkey.slice(0, 8)} — no text, no attachments`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
/** Images become file parts; unreadable files become explicit model context. */
|
|
342
|
+
const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
|
|
343
|
+
const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log);
|
|
344
|
+
if (result.status === "waiting" && result.inputRequests.length > 0) {
|
|
345
|
+
sessions.set(from, channel, {
|
|
346
|
+
id: result.sessionId,
|
|
347
|
+
streamIndex: result.streamIndex,
|
|
348
|
+
pendingInputRequests: result.inputRequests,
|
|
349
|
+
speakerPublicKey: event.pubkey,
|
|
350
|
+
});
|
|
351
|
+
const promptId = relay.reply(channel, formatInputRequests(result.inputRequests), event);
|
|
352
|
+
if (promptId) {
|
|
353
|
+
relay.watchReplies([promptId]);
|
|
354
|
+
const current = sessions.get(from, channel);
|
|
355
|
+
if (current)
|
|
356
|
+
sessions.set(from, channel, { ...current, promptEventIds: [promptId] });
|
|
357
|
+
}
|
|
358
|
+
log(`posted ${result.inputRequests.length} input request(s) in ${channel.slice(0, 8)}`);
|
|
359
|
+
startFollower(from, channel);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (!result.message) {
|
|
363
|
+
log(`no text for ${channel.slice(0, 8)} — telling them rather than going quiet`);
|
|
364
|
+
relay.reply(channel, "I didn't get an answer back for that one — ask me again and I'll retry.", event);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
relay.reply(channel, result.message, event);
|
|
368
|
+
log(`replied (${result.message.length} chars)`);
|
|
369
|
+
});
|
|
249
370
|
}
|
|
250
371
|
catch (error) {
|
|
251
|
-
/**
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
372
|
+
/** A rejected HITL response stays pending; the room gets a concrete retry path. */
|
|
373
|
+
const stillPending = sessions.get(from, channel)?.pendingInputRequests;
|
|
374
|
+
if (stillPending?.length) {
|
|
375
|
+
log(`could not submit pending input for ${channel.slice(0, 8)}: ${error.message}`);
|
|
376
|
+
relay.reply(channel, invalidInputReply(stillPending), event);
|
|
377
|
+
startFollower(from, channel);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
257
380
|
const rejected = rejectedTurnReply(error);
|
|
258
381
|
if (rejected) {
|
|
259
382
|
log(`turn rejected for ${channel.slice(0, 8)}: ${error.message}`);
|
|
@@ -273,14 +396,30 @@ export function buzzBridge(options) {
|
|
|
273
396
|
/** The communities this agent is a member of. */
|
|
274
397
|
relays: urls,
|
|
275
398
|
start() {
|
|
399
|
+
stopped = false;
|
|
276
400
|
for (const relay of relays.values())
|
|
277
401
|
relay.connect();
|
|
402
|
+
for (const { community, channel, session } of sessions.entries()) {
|
|
403
|
+
if (session.pendingInputRequests?.length && session.promptEventIds?.length) {
|
|
404
|
+
relays.get(community)?.watchReplies(session.promptEventIds);
|
|
405
|
+
}
|
|
406
|
+
if (needsPendingFollower(session) && session.speakerPublicKey) {
|
|
407
|
+
startFollower(community, channel);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
278
410
|
log(urls.length > 1
|
|
279
411
|
? `listening on ${urls.length} communities — turns run as whoever sent them`
|
|
280
412
|
: "listening — turns run as whoever sent them");
|
|
281
413
|
},
|
|
282
414
|
/** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
|
|
283
415
|
stop() {
|
|
416
|
+
stopped = true;
|
|
417
|
+
for (const controller of followers.values())
|
|
418
|
+
controller.abort();
|
|
419
|
+
followers.clear();
|
|
420
|
+
for (const timer of followerRestartTimers.values())
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
followerRestartTimers.clear();
|
|
284
423
|
for (const relay of relays.values())
|
|
285
424
|
relay.setPresence("offline");
|
|
286
425
|
setTimeout(() => {
|
package/dist/hitl.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ClientSession, type InputRequest, type InputResponse } from "eve/client";
|
|
2
|
+
import type { StoredSession } from "./sessions.js";
|
|
3
|
+
/** Render pending Eve input in a text-only Buzz channel. */
|
|
4
|
+
export declare function formatInputRequests(requests: readonly InputRequest[]): string;
|
|
5
|
+
/** Resolve a Buzz text reply using Eve's certified matching semantics. */
|
|
6
|
+
export declare function resolveInputReply(text: string, requests: readonly InputRequest[]): readonly InputResponse[] | null;
|
|
7
|
+
/** Actionable response for text that cannot satisfy every pending request. */
|
|
8
|
+
export declare function invalidInputReply(requests: readonly InputRequest[]): string;
|
|
9
|
+
/** Submit a matched reply without consuming its response stream. */
|
|
10
|
+
export declare function respondToPendingConversation(session: Pick<ClientSession, "respond">, responses: readonly InputResponse[], community: string, channel: string): Promise<void>;
|
|
11
|
+
/** A session needs its follower while asking or while resumed output is unpublished. */
|
|
12
|
+
export declare function needsPendingFollower(session: Pick<StoredSession, "pendingInputRequests" | "resumeInFlight">): boolean;
|
|
13
|
+
export interface PendingFollowerCallbacks {
|
|
14
|
+
/** Persist the latest cursor and supervision state before publishing outward. */
|
|
15
|
+
onState(state: Omit<StoredSession, "updated">): void | Promise<void>;
|
|
16
|
+
onInputRequested(requests: readonly InputRequest[]): void | Promise<void>;
|
|
17
|
+
onMessage(message: string): void | Promise<void>;
|
|
18
|
+
onLog?: (message: string) => void;
|
|
19
|
+
}
|
|
20
|
+
type MutableStoredSession = Omit<StoredSession, "updated">;
|
|
21
|
+
/**
|
|
22
|
+
* Follow one parked conversation until it resumes, asks another HITL question,
|
|
23
|
+
* or reaches a terminal/non-HITL boundary. This follower is the sole publisher
|
|
24
|
+
* of resumed output; callers of `respond()` must not consume or publish its
|
|
25
|
+
* response stream as well.
|
|
26
|
+
*
|
|
27
|
+
* The durable `resumeInFlight` marker deliberately survives `input.resolved`.
|
|
28
|
+
* It is cleared only after the relay publication callback succeeds, so a crash
|
|
29
|
+
* or disconnect between Eve accepting input and Buzz publishing output can
|
|
30
|
+
* reattach from the persisted cursor instead of letting a normal stale drain
|
|
31
|
+
* swallow the resumed answer.
|
|
32
|
+
*/
|
|
33
|
+
export declare function followPendingConversation(session: Pick<ClientSession, "state" | "stream">, initial: MutableStoredSession, callbacks: PendingFollowerCallbacks, signal: AbortSignal): Promise<void>;
|
|
34
|
+
export {};
|
package/dist/hitl.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { resolveTextToResponses, } from "eve/client";
|
|
2
|
+
/** Render pending Eve input in a text-only Buzz channel. */
|
|
3
|
+
export function formatInputRequests(requests) {
|
|
4
|
+
return requests.map((request, requestIndex) => {
|
|
5
|
+
const lines = [
|
|
6
|
+
requests.length > 1 ? `Question ${requestIndex + 1}: ${request.prompt}` : request.prompt,
|
|
7
|
+
];
|
|
8
|
+
if (request.options?.length) {
|
|
9
|
+
lines.push("", ...request.options.map((option, optionIndex) => {
|
|
10
|
+
const description = option.description ? ` — ${option.description}` : "";
|
|
11
|
+
return `${optionIndex + 1}. ${option.label} [${option.id}]${description}`;
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
if (request.allowFreeform === true || !request.options?.length) {
|
|
15
|
+
lines.push("", "Reply to this message with an option number, option ID, exact label, or your own answer.");
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
lines.push("", "Reply to this message with an option number, option ID, or exact label.");
|
|
19
|
+
}
|
|
20
|
+
return lines.join("\n");
|
|
21
|
+
}).join("\n\n");
|
|
22
|
+
}
|
|
23
|
+
/** Resolve a Buzz text reply using Eve's certified matching semantics. */
|
|
24
|
+
export function resolveInputReply(text, requests) {
|
|
25
|
+
if (!text.trim())
|
|
26
|
+
return null;
|
|
27
|
+
const responses = resolveTextToResponses(text, requests);
|
|
28
|
+
return responses.length === requests.length ? responses : null;
|
|
29
|
+
}
|
|
30
|
+
/** Actionable response for text that cannot satisfy every pending request. */
|
|
31
|
+
export function invalidInputReply(requests) {
|
|
32
|
+
return `I still need a valid answer before I can continue.\n\n${formatInputRequests(requests)}`;
|
|
33
|
+
}
|
|
34
|
+
/** Submit a matched reply without consuming its response stream. */
|
|
35
|
+
export async function respondToPendingConversation(session, responses, community, channel) {
|
|
36
|
+
await session.respond(responses, {
|
|
37
|
+
clientContext: { buzzCommunity: community, buzzChannel: channel },
|
|
38
|
+
streamReconnectPolicy: { reconnect: false },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** A session needs its follower while asking or while resumed output is unpublished. */
|
|
42
|
+
export function needsPendingFollower(session) {
|
|
43
|
+
return Boolean(session.pendingInputRequests?.length || session.resumeInFlight);
|
|
44
|
+
}
|
|
45
|
+
function withPending(state, requests) {
|
|
46
|
+
const { resumeInFlight: _, ...rest } = state;
|
|
47
|
+
return { ...rest, pendingInputRequests: requests };
|
|
48
|
+
}
|
|
49
|
+
function withResumeInFlight(state) {
|
|
50
|
+
const { pendingInputRequests: _, ...rest } = state;
|
|
51
|
+
return { ...rest, resumeInFlight: true };
|
|
52
|
+
}
|
|
53
|
+
function withoutSupervision(state) {
|
|
54
|
+
const { pendingInputRequests: _, resumeInFlight: __, ...rest } = state;
|
|
55
|
+
return rest;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Follow one parked conversation until it resumes, asks another HITL question,
|
|
59
|
+
* or reaches a terminal/non-HITL boundary. This follower is the sole publisher
|
|
60
|
+
* of resumed output; callers of `respond()` must not consume or publish its
|
|
61
|
+
* response stream as well.
|
|
62
|
+
*
|
|
63
|
+
* The durable `resumeInFlight` marker deliberately survives `input.resolved`.
|
|
64
|
+
* It is cleared only after the relay publication callback succeeds, so a crash
|
|
65
|
+
* or disconnect between Eve accepting input and Buzz publishing output can
|
|
66
|
+
* reattach from the persisted cursor instead of letting a normal stale drain
|
|
67
|
+
* swallow the resumed answer.
|
|
68
|
+
*/
|
|
69
|
+
export async function followPendingConversation(session, initial, callbacks, signal) {
|
|
70
|
+
let state = { ...initial };
|
|
71
|
+
let streamIndex = initial.streamIndex;
|
|
72
|
+
let completedMessage = "";
|
|
73
|
+
const persist = async () => {
|
|
74
|
+
state = { ...state, streamIndex };
|
|
75
|
+
await callbacks.onState(state);
|
|
76
|
+
};
|
|
77
|
+
const publishThenClear = async () => {
|
|
78
|
+
// Do not persist past message.completed/turn.completed until publication
|
|
79
|
+
// succeeds. If this callback throws or the process dies, recovery replays
|
|
80
|
+
// the unpublished output from the last safe cursor.
|
|
81
|
+
if (completedMessage.trim())
|
|
82
|
+
await callbacks.onMessage(completedMessage);
|
|
83
|
+
state = withoutSupervision(state);
|
|
84
|
+
await persist();
|
|
85
|
+
};
|
|
86
|
+
for await (const event of session.stream({
|
|
87
|
+
follow: true,
|
|
88
|
+
startIndex: initial.streamIndex,
|
|
89
|
+
signal,
|
|
90
|
+
})) {
|
|
91
|
+
if (signal.aborted)
|
|
92
|
+
return;
|
|
93
|
+
const streamEvent = event;
|
|
94
|
+
// ClientSession updates its public cursor only when the async iterator
|
|
95
|
+
// closes; count yielded durable events so crash recovery can persist while
|
|
96
|
+
// the follower is still open.
|
|
97
|
+
streamIndex += 1;
|
|
98
|
+
switch (streamEvent.type) {
|
|
99
|
+
case "input.requested":
|
|
100
|
+
completedMessage = "";
|
|
101
|
+
state = withPending(state, streamEvent.data.requests);
|
|
102
|
+
// Keep the durable cursor before the prompt until Buzz accepts it for
|
|
103
|
+
// relay delivery; a reconnect can then replay and render the request.
|
|
104
|
+
await callbacks.onInputRequested(streamEvent.data.requests);
|
|
105
|
+
await persist();
|
|
106
|
+
break;
|
|
107
|
+
case "input.resolved": {
|
|
108
|
+
const resolved = new Set(streamEvent.data.resolutions.map((resolution) => resolution.requestId));
|
|
109
|
+
const remaining = state.pendingInputRequests?.filter((request) => !resolved.has(request.requestId)) ?? [];
|
|
110
|
+
state = remaining.length > 0 ? withPending(state, remaining) : withResumeInFlight(state);
|
|
111
|
+
await persist();
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "message.completed":
|
|
115
|
+
// A resumed turn can end more than one step with prose (the answer, then a wrap-up line).
|
|
116
|
+
// The room gets the whole turn, not whichever message happened to come last.
|
|
117
|
+
if (streamEvent.data.finishReason !== "tool-calls" && streamEvent.data.message) {
|
|
118
|
+
completedMessage = completedMessage
|
|
119
|
+
? `${completedMessage}\n\n${streamEvent.data.message}`
|
|
120
|
+
: streamEvent.data.message;
|
|
121
|
+
}
|
|
122
|
+
// The durable cursor remains before unpublished output. A restarted
|
|
123
|
+
// follower may replay this event safely because it is the sole publisher.
|
|
124
|
+
break;
|
|
125
|
+
case "turn.completed":
|
|
126
|
+
case "session.waiting":
|
|
127
|
+
if (!state.pendingInputRequests?.length) {
|
|
128
|
+
await publishThenClear();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
await persist();
|
|
132
|
+
break;
|
|
133
|
+
case "turn.cancelled":
|
|
134
|
+
case "turn.failed":
|
|
135
|
+
case "session.failed":
|
|
136
|
+
case "session.completed":
|
|
137
|
+
state = withoutSupervision(state);
|
|
138
|
+
await persist();
|
|
139
|
+
return;
|
|
140
|
+
default:
|
|
141
|
+
// Replaying progress events after a crash has no outward side effect.
|
|
142
|
+
// Persist semantic boundaries rather than writing the session file for
|
|
143
|
+
// every token delta.
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
callbacks.onLog?.("pending Eve stream ended before a resumable boundary");
|
|
148
|
+
}
|
package/dist/relay.d.ts
CHANGED
|
@@ -64,6 +64,8 @@ export declare class BuzzRelay {
|
|
|
64
64
|
/** Answered already, so a redelivery in two overlapping polls is not answered twice. */
|
|
65
65
|
private readonly seen;
|
|
66
66
|
private cursor;
|
|
67
|
+
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
68
|
+
private readonly replyWatch;
|
|
67
69
|
private readonly url;
|
|
68
70
|
private readonly rest;
|
|
69
71
|
private readonly key;
|
|
@@ -72,6 +74,9 @@ export declare class BuzzRelay {
|
|
|
72
74
|
private readonly log;
|
|
73
75
|
constructor(options: RelayOptions);
|
|
74
76
|
get pubkey(): string;
|
|
77
|
+
/** Also poll for replies to these events — a person answering a prompt rarely mentions the asker. */
|
|
78
|
+
watchReplies(ids: Iterable<string>): void;
|
|
79
|
+
unwatchReplies(ids: Iterable<string>): void;
|
|
75
80
|
connect(): void;
|
|
76
81
|
close(): void;
|
|
77
82
|
private reconnect;
|
|
@@ -86,17 +91,18 @@ export declare class BuzzRelay {
|
|
|
86
91
|
* failure — a process that connects, authenticates, logs that it is listening, and silently
|
|
87
92
|
* ignores every message addressed to it.
|
|
88
93
|
*/
|
|
89
|
-
|
|
94
|
+
poll(): void;
|
|
95
|
+
/** Publish one event; returns its id when it left this process, null when the socket was down. */
|
|
90
96
|
publish(event: {
|
|
91
97
|
kind: number;
|
|
92
98
|
tags: string[][];
|
|
93
99
|
content: string;
|
|
94
|
-
}):
|
|
100
|
+
}): string | null;
|
|
95
101
|
setPresence(status: PresenceStatus): void;
|
|
96
102
|
private keepPresent;
|
|
97
103
|
/** Say "typing" in a channel until the returned function is called. */
|
|
98
104
|
typingIn(channel: string): () => void;
|
|
99
|
-
reply(channel: string, text: string, replyTo?: NostrEvent):
|
|
105
|
+
reply(channel: string, text: string, replyTo?: NostrEvent): string | null;
|
|
100
106
|
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
101
107
|
private httpAuth;
|
|
102
108
|
private submit;
|
package/dist/relay.js
CHANGED
|
@@ -60,6 +60,8 @@ export class BuzzRelay {
|
|
|
60
60
|
/** Answered already, so a redelivery in two overlapping polls is not answered twice. */
|
|
61
61
|
seen = new Set();
|
|
62
62
|
cursor = now();
|
|
63
|
+
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
64
|
+
replyWatch = new Set();
|
|
63
65
|
url;
|
|
64
66
|
rest;
|
|
65
67
|
key;
|
|
@@ -77,6 +79,15 @@ export class BuzzRelay {
|
|
|
77
79
|
get pubkey() {
|
|
78
80
|
return this.key.publicKey;
|
|
79
81
|
}
|
|
82
|
+
/** Also poll for replies to these events — a person answering a prompt rarely mentions the asker. */
|
|
83
|
+
watchReplies(ids) {
|
|
84
|
+
for (const id of ids)
|
|
85
|
+
this.replyWatch.add(id);
|
|
86
|
+
}
|
|
87
|
+
unwatchReplies(ids) {
|
|
88
|
+
for (const id of ids)
|
|
89
|
+
this.replyWatch.delete(id);
|
|
90
|
+
}
|
|
80
91
|
connect() {
|
|
81
92
|
if (this.closed)
|
|
82
93
|
return;
|
|
@@ -210,8 +221,9 @@ export class BuzzRelay {
|
|
|
210
221
|
}
|
|
211
222
|
send(frame) {
|
|
212
223
|
if (this.socket?.readyState !== WebSocket.OPEN)
|
|
213
|
-
return;
|
|
224
|
+
return false;
|
|
214
225
|
this.socket.send(JSON.stringify(frame));
|
|
226
|
+
return true;
|
|
215
227
|
}
|
|
216
228
|
/**
|
|
217
229
|
* Ask for what is new, rather than waiting to be told.
|
|
@@ -225,11 +237,19 @@ export class BuzzRelay {
|
|
|
225
237
|
poll() {
|
|
226
238
|
if (this.closed)
|
|
227
239
|
return;
|
|
228
|
-
|
|
240
|
+
const filters = [
|
|
241
|
+
{ kinds: [KIND_MESSAGE], "#p": [this.key.publicKey], since: this.cursor },
|
|
242
|
+
];
|
|
243
|
+
if (this.replyWatch.size > 0) {
|
|
244
|
+
filters.push({ kinds: [KIND_MESSAGE], "#e": [...this.replyWatch], since: this.cursor });
|
|
245
|
+
}
|
|
246
|
+
this.send(["REQ", `m${Date.now()}`, ...filters]);
|
|
229
247
|
this.pollTimer = setTimeout(() => this.poll(), this.pollMs);
|
|
230
248
|
}
|
|
249
|
+
/** Publish one event; returns its id when it left this process, null when the socket was down. */
|
|
231
250
|
publish(event) {
|
|
232
|
-
|
|
251
|
+
const signed = finalizeEvent({ created_at: now(), ...event }, this.key.secretKey);
|
|
252
|
+
return this.send(["EVENT", signed]) ? signed.id : null;
|
|
233
253
|
}
|
|
234
254
|
setPresence(status) {
|
|
235
255
|
this.publish({ kind: KIND_PRESENCE, tags: [], content: status });
|
|
@@ -252,7 +272,7 @@ export class BuzzRelay {
|
|
|
252
272
|
if (replyTo) {
|
|
253
273
|
tags.push(["e", replyTo.id], ["p", replyTo.pubkey]);
|
|
254
274
|
}
|
|
255
|
-
this.publish({ kind: KIND_MESSAGE, tags, content: text });
|
|
275
|
+
return this.publish({ kind: KIND_MESSAGE, tags, content: text });
|
|
256
276
|
}
|
|
257
277
|
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
258
278
|
httpAuth(url, body) {
|
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,7 +38,8 @@ export interface StoredSession {
|
|
|
21
38
|
* other, and the join between them was the part that could not survive.
|
|
22
39
|
*
|
|
23
40
|
* Keyed by community AND channel: channel ids are issued per relay, so an agent
|
|
24
|
-
* in two workspaces cannot assume they never collide.
|
|
41
|
+
* in two workspaces cannot assume they never collide. Optional HITL fields keep
|
|
42
|
+
* records written by older Buzz versions valid.
|
|
25
43
|
*/
|
|
26
44
|
export declare class SessionStore {
|
|
27
45
|
#private;
|
|
@@ -30,10 +48,8 @@ export declare class SessionStore {
|
|
|
30
48
|
});
|
|
31
49
|
static key(community: string, channel: string): string;
|
|
32
50
|
get(community: string, channel: string): StoredSession | undefined;
|
|
33
|
-
set(community: string, channel: string, session:
|
|
34
|
-
id: string;
|
|
35
|
-
streamIndex: number;
|
|
36
|
-
}): void;
|
|
51
|
+
set(community: string, channel: string, session: Omit<StoredSession, "updated">): void;
|
|
37
52
|
delete(community: string, channel: string): void;
|
|
53
|
+
entries(): StoredSessionEntry[];
|
|
38
54
|
get size(): number;
|
|
39
55
|
}
|
package/dist/sessions.js
CHANGED
|
@@ -18,7 +18,8 @@ const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
18
18
|
* other, and the join between them was the part that could not survive.
|
|
19
19
|
*
|
|
20
20
|
* Keyed by community AND channel: channel ids are issued per relay, so an agent
|
|
21
|
-
* in two workspaces cannot assume they never collide.
|
|
21
|
+
* in two workspaces cannot assume they never collide. Optional HITL fields keep
|
|
22
|
+
* records written by older Buzz versions valid.
|
|
22
23
|
*/
|
|
23
24
|
export class SessionStore {
|
|
24
25
|
#file;
|
|
@@ -43,6 +44,20 @@ export class SessionStore {
|
|
|
43
44
|
this.#entries.delete(SessionStore.key(community, channel));
|
|
44
45
|
this.#save();
|
|
45
46
|
}
|
|
47
|
+
entries() {
|
|
48
|
+
const entries = [];
|
|
49
|
+
for (const [key, session] of this.#entries) {
|
|
50
|
+
const separator = key.lastIndexOf("|");
|
|
51
|
+
if (separator < 0)
|
|
52
|
+
continue;
|
|
53
|
+
entries.push({
|
|
54
|
+
community: key.slice(0, separator),
|
|
55
|
+
channel: key.slice(separator + 1),
|
|
56
|
+
session,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return entries;
|
|
60
|
+
}
|
|
46
61
|
get size() {
|
|
47
62
|
return this.#entries.size;
|
|
48
63
|
}
|
|
@@ -53,7 +68,9 @@ export class SessionStore {
|
|
|
53
68
|
const raw = JSON.parse(readFileSync(this.#file, "utf8"));
|
|
54
69
|
const cutoff = Date.now() - KEEP_MS;
|
|
55
70
|
for (const [key, value] of Object.entries(raw)) {
|
|
56
|
-
if (typeof value?.id === "string" &&
|
|
71
|
+
if (typeof value?.id === "string" &&
|
|
72
|
+
typeof value?.streamIndex === "number" &&
|
|
73
|
+
(value.updated ?? 0) > cutoff) {
|
|
57
74
|
this.#entries.set(key, value);
|
|
58
75
|
}
|
|
59
76
|
}
|
package/dist/turn.d.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import { type Client, type SendTurnInput } from "eve/client";
|
|
1
|
+
import { type Client, type InputRequest, type MessageResult, type SendTurnInput } from "eve/client";
|
|
2
2
|
import { type FetchedMedia, type MediaRef } from "./media.js";
|
|
3
3
|
import { SessionStore } from "./sessions.js";
|
|
4
4
|
/** A message shape accepted by the certified eve client's send contract. */
|
|
5
5
|
export type TurnMessage = SendTurnInput["message"];
|
|
6
|
+
/** Everything the bridge needs to decide how one eve turn should be rendered. */
|
|
7
|
+
export interface TurnOutcome {
|
|
8
|
+
message: string;
|
|
9
|
+
status: MessageResult["status"];
|
|
10
|
+
inputRequests: readonly InputRequest[];
|
|
11
|
+
sessionId: string;
|
|
12
|
+
streamIndex: number;
|
|
13
|
+
}
|
|
6
14
|
/** Convert one inbound Buzz message into an eve-compatible turn. */
|
|
7
15
|
export declare function composeMessage(text: string, attachments: MediaRef[], fetchAttachment: (ref: MediaRef) => Promise<FetchedMedia>, log?: (message: string) => void): Promise<TurnMessage>;
|
|
8
16
|
/** Continue a channel's existing conversation, replacing it only when it is stale. */
|
|
9
|
-
export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void): Promise<
|
|
17
|
+
export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void): Promise<TurnOutcome>;
|
|
10
18
|
/**
|
|
11
19
|
* What the room is told when eve refuses the turn itself.
|
|
12
20
|
*
|
package/dist/turn.js
CHANGED
|
@@ -33,6 +33,15 @@ export async function composeMessage(text, attachments, fetchAttachment, log = (
|
|
|
33
33
|
}
|
|
34
34
|
return parts;
|
|
35
35
|
}
|
|
36
|
+
function outcome(result, streamIndex) {
|
|
37
|
+
return {
|
|
38
|
+
message: result.message ?? "",
|
|
39
|
+
status: result.status,
|
|
40
|
+
inputRequests: result.inputRequests,
|
|
41
|
+
sessionId: result.sessionId,
|
|
42
|
+
streamIndex,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
36
45
|
/** Continue a channel's existing conversation, replacing it only when it is stale. */
|
|
37
46
|
export async function answerTurn(client, sessions, channel, message, community, log = () => { }) {
|
|
38
47
|
const existing = sessions.get(community, channel);
|
|
@@ -58,6 +67,9 @@ export async function answerTurn(client, sessions, channel, message, community,
|
|
|
58
67
|
* repairs a position that has already drifted rather than requiring
|
|
59
68
|
* anyone to notice. Non-following, so it ends at the tail instead of
|
|
60
69
|
* waiting for the future.
|
|
70
|
+
*
|
|
71
|
+
* Do not call this path while HITL is pending: the bridge's follower owns
|
|
72
|
+
* that unread stream until the parked turn reaches its next boundary.
|
|
61
73
|
*/
|
|
62
74
|
let unread = 0;
|
|
63
75
|
for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
|
|
@@ -74,7 +86,7 @@ export async function answerTurn(client, sessions, channel, message, community,
|
|
|
74
86
|
id: existing.id,
|
|
75
87
|
streamIndex: session.state.streamIndex,
|
|
76
88
|
});
|
|
77
|
-
return result.
|
|
89
|
+
return outcome(result, session.state.streamIndex);
|
|
78
90
|
}
|
|
79
91
|
catch (error) {
|
|
80
92
|
// A malformed turn does not make its durable session stale. Rethrow so
|
|
@@ -90,12 +102,12 @@ export async function answerTurn(client, sessions, channel, message, community,
|
|
|
90
102
|
message,
|
|
91
103
|
clientContext: { buzzCommunity: community, buzzChannel: channel },
|
|
92
104
|
});
|
|
93
|
-
const
|
|
105
|
+
const result = await created.response.result();
|
|
94
106
|
sessions.set(community, channel, {
|
|
95
107
|
id: created.session.state.sessionId,
|
|
96
108
|
streamIndex: created.session.state.streamIndex,
|
|
97
109
|
});
|
|
98
|
-
return
|
|
110
|
+
return outcome(result, created.session.state.streamIndex);
|
|
99
111
|
}
|
|
100
112
|
/**
|
|
101
113
|
* What the room is told when eve refuses the turn itself.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.9.
|
|
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",
|