@kybernesis/buzz 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -1
- package/dist/bridge.d.ts +12 -1
- package/dist/bridge.js +120 -24
- package/dist/cli.js +2 -0
- package/dist/hitl.d.ts +88 -8
- package/dist/hitl.js +202 -17
- package/dist/relay.d.ts +25 -1
- package/dist/relay.js +53 -2
- package/dist/sessions.d.ts +20 -0
- package/dist/turn.d.ts +4 -3
- package/dist/turn.js +8 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -95,6 +95,34 @@ share link as the person who asked (`POST /eve/v1/kyb/artifacts/:id/share`, `@ky
|
|
|
95
95
|
app and opens it beside the conversation; everywhere else the link is in the text. The person
|
|
96
96
|
can withdraw the link from the agent. `BUZZ_CARDS=off` stops card messages.
|
|
97
97
|
|
|
98
|
+
## Questions and approvals
|
|
99
|
+
|
|
100
|
+
When the agent needs a yes or an answer, the bridge posts the question as a reply to the message
|
|
101
|
+
that asked: an approval as the email, event or message it would send (recipients, subject and
|
|
102
|
+
the whole body, not "Approve tool call"), any other question as its sentence, then the options by
|
|
103
|
+
number. The person who asked answers by replying with a number, an option or their own words. Since 0.14 the
|
|
104
|
+
question also travels hidden (`<!-- kyb:ask v1 {...} -->`), and the plugin draws it as Studio
|
|
105
|
+
does, with Send it, Edit and Don't send on an email, the amount and Approve in Link on a
|
|
106
|
+
purchase, and the fields of a form. A press posts the person's answer as a reply to the
|
|
107
|
+
question: a short line, with the answer hidden after it (`<!-- kyb:answer v1 {...} -->`). The
|
|
108
|
+
bridge accepts a hidden answer only as a direct reply to a prompt it posted, only for a request
|
|
109
|
+
still open, and only with that request's own options. Replies inside a thread count, whichever
|
|
110
|
+
`e` tag names the prompt. A vault request never takes a secret in the channel.
|
|
111
|
+
|
|
112
|
+
Since 0.15 a question is for the person whose message raised it. Anyone else who presses an
|
|
113
|
+
answer, or names the agent while it waits, is told whose answer it needs; a remark in the
|
|
114
|
+
question's thread is left alone. The question carries who asked, so the plugin shows everyone
|
|
115
|
+
else the email with "Waiting on" and no buttons. `BUZZ_ANSWERS=anyone` keeps the older rule.
|
|
116
|
+
When a question ends (answered, declined, sent back with changes, or no longer waited on because
|
|
117
|
+
its work stopped) the bridge edits its own prompt to say how and by whom, with the question
|
|
118
|
+
hidden as `<!-- kyb:settled v1 {...} -->` instead. Only a message's author can edit it, so every
|
|
119
|
+
client, with the plugin or without, shows the same outcome and nobody is offered the buttons
|
|
120
|
+
again. The edit names nobody, so it notifies nobody.
|
|
121
|
+
|
|
122
|
+
If the work an answer restarts fails (a model refusing the continuation, say), the bridge says so
|
|
123
|
+
in the channel with the stream's own error, rather than leaving the person who answered with
|
|
124
|
+
silence. A declined call is the person's choice and is not reported as a failure.
|
|
125
|
+
|
|
98
126
|
## Configuration
|
|
99
127
|
|
|
100
128
|
| Variable | Meaning |
|
|
@@ -105,7 +133,8 @@ can withdraw the link from the agent. `BUZZ_CARDS=off` stops card messages.
|
|
|
105
133
|
| `BUZZ_SESSIONS_FILE` | Channel-to-session continuity store (default beside the key file; an existing legacy `.buzz-sessions.json` is reused) |
|
|
106
134
|
| `BUZZ_AGENT_SILENCE_TIMEOUT_MS` | Maximum silence before the agent acknowledges a request: unread drain, send and create acknowledgement (default `300000`, five minutes). This is where a genuine stall shows: no run is ever created. |
|
|
107
135
|
| `BUZZ_AGENT_WORK_TIMEOUT_MS` | Ceiling on silence once the response stream is open (default `3600000`, sixty minutes). The stream is quiet while the agent works, a long tool call or a delegated subagent produces nothing until it returns, so this is a ceiling on work, not a stall detector. |
|
|
108
|
-
| `BUZZ_CARDS` | `off` stops posting cards after replies (default on) |
|
|
136
|
+
| `BUZZ_CARDS` | `off` stops posting cards after replies and hidden questions in prompts (default on) |
|
|
137
|
+
| `BUZZ_ANSWERS` | Who may answer a question in a shared channel: `asker` (default), only the person whose message raised it, or `anyone` the agent knows there |
|
|
109
138
|
| `BUZZ_PROFILE_SYNC_MS` | How often the profile is re-checked against the name and picture its owner set and its `.agent` name (default `900000`, fifteen minutes; `0` turns it off) |
|
|
110
139
|
| `AGENTID_MIRROR_SUFFIX` | Where names serve their public documents (default `.agentid.dev`); for staging only |
|
|
111
140
|
| `KYBERNESIS_ISSUER` | The control plane |
|
package/dist/bridge.d.ts
CHANGED
|
@@ -26,9 +26,20 @@ export type BuzzBridgeOptions = {
|
|
|
26
26
|
/**
|
|
27
27
|
* Post the cards a turn's tools handed back, each as its own message after
|
|
28
28
|
* the reply: the card's text, with the card hidden in a comment for clients
|
|
29
|
-
* that draw it.
|
|
29
|
+
* that draw it. Questions carry themselves the same way (0.14), so the
|
|
30
|
+
* plugin can draw an approval as its email and answer it with a press.
|
|
31
|
+
* Default true; false posts the reply and questions as words alone.
|
|
30
32
|
*/
|
|
31
33
|
cards?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Who may answer a question the agent asks in a shared channel. `asker`
|
|
36
|
+
* (the default): only the person whose message raised it; anyone else is
|
|
37
|
+
* told so, and the plugin shows them the question without buttons.
|
|
38
|
+
* `anyone`: anyone the agent knows in the channel, as before 0.15.
|
|
39
|
+
* Either way, once a question ends the bridge edits it to say how, for
|
|
40
|
+
* everyone who can see it.
|
|
41
|
+
*/
|
|
42
|
+
answers?: "asker" | "anyone";
|
|
32
43
|
onLog?: (message: string) => void;
|
|
33
44
|
};
|
|
34
45
|
export declare function buzzBridge(options: BuzzBridgeOptions): {
|
package/dist/bridge.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { cardMessage, cardText } from "@kybernesis/cards";
|
|
1
|
+
import { cardMessage, cardText, readHiddenAnswers } from "@kybernesis/cards";
|
|
2
2
|
import { channelIdentity } from "@kybernesis/enterprise";
|
|
3
3
|
import { fetchMedia, parseMedia } from "./media.js";
|
|
4
4
|
import { speakerCredentials } from "./credentials.js";
|
|
5
5
|
import { surfaceWriter } from "./surface.js";
|
|
6
6
|
import { SessionStore } from "./sessions.js";
|
|
7
7
|
import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, DEFAULT_AGENT_WORK_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
|
|
8
|
-
import { followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, resumeStalledReply, } from "./hitl.js";
|
|
8
|
+
import { applySettlements, followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, isAsker, needsPendingFollower, notTheAskerReply, settlementsFor, settleRemaining, resolveCarriedAnswers, resolveInputReply, respondToPendingConversation, resumeFailedReply, resumeStalledReply, STALE_ANSWER_REPLY, } from "./hitl.js";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { Client } from "eve/client";
|
|
@@ -35,6 +35,8 @@ export function buzzBridge(options) {
|
|
|
35
35
|
const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
|
|
36
36
|
const agentWorkTimeoutMs = validateAgentSilenceTimeoutMs(options.agentWorkTimeoutMs ?? DEFAULT_AGENT_WORK_TIMEOUT_MS);
|
|
37
37
|
const agentSilenceTimeoutMs = validateAgentSilenceTimeoutMs(options.agentSilenceTimeoutMs ?? DEFAULT_AGENT_SILENCE_TIMEOUT_MS);
|
|
38
|
+
const askerOnly = options.answers !== "anyone";
|
|
39
|
+
const carry = options.cards !== false;
|
|
38
40
|
/**
|
|
39
41
|
* An artifact card, with a link everyone in the channel can open.
|
|
40
42
|
*
|
|
@@ -262,6 +264,49 @@ export function buzzBridge(options) {
|
|
|
262
264
|
followers.get(conversation)?.abort();
|
|
263
265
|
followers.delete(conversation);
|
|
264
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Edit each posted question whose requests just ended, so everyone who can
|
|
269
|
+
* see it reads how it ended and nobody is offered its buttons again.
|
|
270
|
+
*
|
|
271
|
+
* @remarks
|
|
272
|
+
* The plugin used to keep a pressed question's outcome only on the device
|
|
273
|
+
* that pressed it. In a shared channel everyone else, and the same person on
|
|
274
|
+
* another device, still saw Send it on an email that had gone. The prompt is
|
|
275
|
+
* the agent's own message and only its author may edit it, so the edit is
|
|
276
|
+
* the one record every client draws. Runs inside the conversation's queue.
|
|
277
|
+
*/
|
|
278
|
+
function settlePrompts(relay, community, channel, settlements) {
|
|
279
|
+
const current = sessions.get(community, channel);
|
|
280
|
+
if (!current?.prompts?.length || settlements.size === 0)
|
|
281
|
+
return;
|
|
282
|
+
const { edits, open } = applySettlements(current.prompts, settlements, { carry, askerOnly });
|
|
283
|
+
for (const { eventId, content } of edits) {
|
|
284
|
+
if (relay.edit(channel, eventId, content))
|
|
285
|
+
log(`marked a question in ${channel.slice(0, 8)} as ${[...new Set(settlements.values())].map((s) => s.outcome).join(", ")}`);
|
|
286
|
+
else
|
|
287
|
+
log(`could not mark a question in ${channel.slice(0, 8)} as ended: not connected`);
|
|
288
|
+
}
|
|
289
|
+
const { prompts: _prompts, ...rest } = current;
|
|
290
|
+
sessions.set(community, channel, open.length ? { ...rest, prompts: open } : rest);
|
|
291
|
+
}
|
|
292
|
+
/** Every question still open in this conversation ends the same way: answered elsewhere, or no longer waited on. */
|
|
293
|
+
function settleOpenPrompts(relay, community, channel, outcome) {
|
|
294
|
+
const current = sessions.get(community, channel);
|
|
295
|
+
if (!current?.prompts?.length)
|
|
296
|
+
return;
|
|
297
|
+
settlePrompts(relay, community, channel, settleRemaining(current.prompts, { outcome, at: Date.now() }));
|
|
298
|
+
}
|
|
299
|
+
/** Remember a posted question, so it can be edited when it ends. */
|
|
300
|
+
function rememberPrompt(community, channel, prompt) {
|
|
301
|
+
const current = sessions.get(community, channel);
|
|
302
|
+
if (!current)
|
|
303
|
+
return;
|
|
304
|
+
sessions.set(community, channel, {
|
|
305
|
+
...current,
|
|
306
|
+
promptEventIds: [...(current.promptEventIds ?? []), prompt.eventId],
|
|
307
|
+
prompts: [...(current.prompts ?? []), prompt],
|
|
308
|
+
});
|
|
309
|
+
}
|
|
265
310
|
function startFollower(community, channel) {
|
|
266
311
|
if (stopped)
|
|
267
312
|
return;
|
|
@@ -286,28 +331,32 @@ export function buzzBridge(options) {
|
|
|
286
331
|
if (followers.get(conversation) !== controller)
|
|
287
332
|
return;
|
|
288
333
|
const current = sessions.get(community, channel);
|
|
334
|
+
// The follower began from a copy; who asked and what was posted are the store's.
|
|
335
|
+
const { prompts: _stale, asker: _staleAsker, ...fresh } = state;
|
|
336
|
+
const kept = {
|
|
337
|
+
...(current?.asker ? { asker: current.asker } : {}),
|
|
338
|
+
...(current?.prompts?.length ? { prompts: current.prompts } : {}),
|
|
339
|
+
};
|
|
289
340
|
if (state.pendingInputRequests?.length) {
|
|
290
|
-
sessions.set(community, channel, { ...
|
|
341
|
+
sessions.set(community, channel, { ...fresh, ...kept, promptEventIds: current?.promptEventIds });
|
|
291
342
|
return;
|
|
292
343
|
}
|
|
293
344
|
if (current?.promptEventIds?.length)
|
|
294
345
|
relay.unwatchReplies(current.promptEventIds);
|
|
295
|
-
sessions.set(community, channel,
|
|
346
|
+
sessions.set(community, channel, { ...fresh, ...kept });
|
|
347
|
+
// Nothing is waiting on a person any more. A question the bridge did not see answered
|
|
348
|
+
// was answered elsewhere if the turn resumed, and is no longer waited on if it ended.
|
|
349
|
+
settleOpenPrompts(relay, community, channel, state.resumeInFlight ? "answered" : "withdrawn");
|
|
296
350
|
}),
|
|
297
351
|
onInputRequested: (requests) => serialize(community, channel, async () => {
|
|
298
352
|
if (followers.get(conversation) !== controller)
|
|
299
353
|
return;
|
|
300
|
-
const
|
|
354
|
+
const asker = sessions.get(community, channel)?.asker;
|
|
355
|
+
const promptId = relay.reply(channel, formatInputRequests(requests, { carry, asker, askerOnly }));
|
|
301
356
|
if (!promptId)
|
|
302
357
|
throw new Error("Buzz relay is not connected");
|
|
303
358
|
relay.watchReplies([promptId]);
|
|
304
|
-
|
|
305
|
-
if (current) {
|
|
306
|
-
sessions.set(community, channel, {
|
|
307
|
-
...current,
|
|
308
|
-
promptEventIds: [...(current.promptEventIds ?? []), promptId],
|
|
309
|
-
});
|
|
310
|
-
}
|
|
359
|
+
rememberPrompt(community, channel, { eventId: promptId, requests, ...(asker ? { asker } : {}) });
|
|
311
360
|
log(`posted another input request in ${channel.slice(0, 8)}`);
|
|
312
361
|
}),
|
|
313
362
|
onMessage: (message, cards) => serialize(community, channel, async () => {
|
|
@@ -319,6 +368,16 @@ export function buzzBridge(options) {
|
|
|
319
368
|
log(`posted resumed reply in ${channel.slice(0, 8)} (${message.length} chars)`);
|
|
320
369
|
await postCards(relay, channel, cards, stored.speakerPublicKey);
|
|
321
370
|
}),
|
|
371
|
+
onFailed: (failures) => serialize(community, channel, async () => {
|
|
372
|
+
if (followers.get(conversation) !== controller)
|
|
373
|
+
return;
|
|
374
|
+
const current = sessions.get(community, channel);
|
|
375
|
+
if (current?.promptEventIds?.length)
|
|
376
|
+
relay.unwatchReplies(current.promptEventIds);
|
|
377
|
+
if (!relay.reply(channel, resumeFailedReply(failures)))
|
|
378
|
+
throw new Error("Buzz relay is not connected");
|
|
379
|
+
log(`resumed turn failed in ${channel.slice(0, 8)} (${describeSilentTurn(failures)}); told the room`);
|
|
380
|
+
}),
|
|
322
381
|
onLog: log,
|
|
323
382
|
}, controller.signal).catch((error) => {
|
|
324
383
|
if (!controller.signal.aborted) {
|
|
@@ -354,7 +413,8 @@ export function buzzBridge(options) {
|
|
|
354
413
|
return;
|
|
355
414
|
if (current.promptEventIds?.length)
|
|
356
415
|
relay.unwatchReplies(current.promptEventIds);
|
|
357
|
-
|
|
416
|
+
settleOpenPrompts(relay, community, channel, "answered");
|
|
417
|
+
const { pendingInputRequests: _p, resumeInFlight: _r, promptEventIds: _e, prompts: _q, ...cleared } = sessions.get(community, channel) ?? current;
|
|
358
418
|
sessions.set(community, channel, cleared);
|
|
359
419
|
relay.reply(channel, resumeStalledReply(agentWorkTimeoutMs));
|
|
360
420
|
log(`gave up on the resumed turn in ${channel.slice(0, 8)}: no progress past index ${retry.index}`);
|
|
@@ -378,9 +438,13 @@ export function buzzBridge(options) {
|
|
|
378
438
|
const addressed = event.tags.some((t) => t[0] === "p" && t[1] === key.publicKey);
|
|
379
439
|
// A reply to a prompt this agent posted is an answer even when it names nobody: people answer
|
|
380
440
|
// a question by replying to it, not by mentioning the asker. Only while that prompt is open.
|
|
381
|
-
|
|
441
|
+
// Any `e` tag counts: a reply inside a thread names the thread's root first and the prompt
|
|
442
|
+
// after it (NIP-10 `root` and `reply` markers), so the first tag is often not the prompt.
|
|
443
|
+
const referenced = event.tags.filter((t) => t[0] === "e" && typeof t[1] === "string").map((t) => t[1]);
|
|
444
|
+
// The message this one answers directly: the `reply` marker, else the last `e` tag.
|
|
445
|
+
const parent = event.tags.find((t) => t[0] === "e" && t[3] === "reply")?.[1] ?? referenced.at(-1);
|
|
382
446
|
const open = channel ? sessions.get(from, channel) : undefined;
|
|
383
|
-
const answersPrompt = Boolean(
|
|
447
|
+
const answersPrompt = Boolean(open?.pendingInputRequests?.length && referenced.some((id) => open.promptEventIds?.includes(id)));
|
|
384
448
|
// In a one-to-one DM every message is to the agent, mention or not, as with Buzz's own
|
|
385
449
|
// agents. Not one written by another agent (it carries its owner's `auth` attestation):
|
|
386
450
|
// two agents in a DM would otherwise answer each other forever.
|
|
@@ -414,9 +478,35 @@ export function buzzBridge(options) {
|
|
|
414
478
|
await serialize(from, channel, async () => {
|
|
415
479
|
const pending = sessions.get(from, channel);
|
|
416
480
|
if (pending?.pendingInputRequests?.length) {
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
|
|
481
|
+
let responses;
|
|
482
|
+
const carried = readHiddenAnswers(text);
|
|
483
|
+
if (askerOnly && pending.asker && !isAsker(pending.asker, event.pubkey, speaker.user.id)) {
|
|
484
|
+
// Someone else's question. A remark in its thread is people talking, not an answer:
|
|
485
|
+
// only a press, or a message that names the agent, is told whose answer it waits for.
|
|
486
|
+
if (carried.length || addressed)
|
|
487
|
+
relay.reply(channel, notTheAskerReply(pending.asker), event);
|
|
488
|
+
log(`ignored an answer in ${channel.slice(0, 8)} from ${speaker.user.email}: the question is for someone else`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (carried.length) {
|
|
492
|
+
// A press in the plugin. It counts only as a direct reply to a prompt this bridge
|
|
493
|
+
// posted, so a message quoting someone else's question cannot answer this one.
|
|
494
|
+
if (!parent || !pending.promptEventIds?.includes(parent)) {
|
|
495
|
+
log(`ignored a pressed answer in ${channel.slice(0, 8)} that does not reply to an open prompt`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const resolved = resolveCarriedAnswers(carried, pending.pendingInputRequests);
|
|
499
|
+
if (!resolved.responses) {
|
|
500
|
+
relay.reply(channel, resolved.stale ? STALE_ANSWER_REPLY : invalidInputReply(pending.pendingInputRequests), event);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
responses = resolved.responses;
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
// "@Ava approve" is an approve. The mention is how the message reached us, not the answer.
|
|
507
|
+
const answer = addressed ? text.replace(/^@\S+\s*/, "") : text;
|
|
508
|
+
responses = resolveInputReply(answer, pending.pendingInputRequests);
|
|
509
|
+
}
|
|
420
510
|
if (!responses) {
|
|
421
511
|
relay.reply(channel, invalidInputReply(pending.pendingInputRequests), event);
|
|
422
512
|
return;
|
|
@@ -432,8 +522,9 @@ export function buzzBridge(options) {
|
|
|
432
522
|
const session = clientFor(event.pubkey).sessions.attach(pending.id, {
|
|
433
523
|
streamIndex: pending.streamIndex,
|
|
434
524
|
});
|
|
435
|
-
await respondToPendingConversation(session, responses
|
|
525
|
+
await respondToPendingConversation(session, responses);
|
|
436
526
|
log(`submitted ${responses.length} input response(s) in ${channel.slice(0, 8)}`);
|
|
527
|
+
const settlements = settlementsFor(responses, pending.pendingInputRequests, speaker.user.displayName?.trim() || undefined);
|
|
437
528
|
/**
|
|
438
529
|
* The answer is in, so this conversation is no longer waiting on a
|
|
439
530
|
* person. It is waiting on the agent, and that is a thing the bridge
|
|
@@ -447,11 +538,15 @@ export function buzzBridge(options) {
|
|
|
447
538
|
* a human, so while that flag was set the room could only be told
|
|
448
539
|
* nothing.
|
|
449
540
|
*/
|
|
541
|
+
// A press answers one question; any others stay open until they are answered too.
|
|
450
542
|
const answered = sessions.get(from, channel);
|
|
451
543
|
if (answered?.pendingInputRequests?.length) {
|
|
544
|
+
const done = new Set(responses.map((response) => response.requestId));
|
|
545
|
+
const remaining = answered.pendingInputRequests.filter((request) => !done.has(request.requestId));
|
|
452
546
|
const { pendingInputRequests: _resolved, ...rest } = answered;
|
|
453
|
-
sessions.set(from, channel, { ...rest, resumeInFlight: true });
|
|
547
|
+
sessions.set(from, channel, remaining.length ? { ...rest, pendingInputRequests: remaining } : { ...rest, resumeInFlight: true });
|
|
454
548
|
}
|
|
549
|
+
settlePrompts(relay, from, channel, settlements);
|
|
455
550
|
return;
|
|
456
551
|
}
|
|
457
552
|
if (pending?.resumeInFlight) {
|
|
@@ -468,18 +563,19 @@ export function buzzBridge(options) {
|
|
|
468
563
|
const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
|
|
469
564
|
const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log, agentSilenceTimeoutMs, agentWorkTimeoutMs);
|
|
470
565
|
if (result.status === "waiting" && result.inputRequests.length > 0) {
|
|
566
|
+
const name = speaker.user.displayName?.trim();
|
|
567
|
+
const asker = { key: event.pubkey, ...(speaker.user.id ? { userId: speaker.user.id } : {}), ...(name ? { name } : {}) };
|
|
471
568
|
sessions.set(from, channel, {
|
|
472
569
|
id: result.sessionId,
|
|
473
570
|
streamIndex: result.streamIndex,
|
|
474
571
|
pendingInputRequests: result.inputRequests,
|
|
475
572
|
speakerPublicKey: event.pubkey,
|
|
573
|
+
asker,
|
|
476
574
|
});
|
|
477
|
-
const promptId = relay.reply(channel, formatInputRequests(result.inputRequests), event);
|
|
575
|
+
const promptId = relay.reply(channel, formatInputRequests(result.inputRequests, { carry, asker, askerOnly }), event);
|
|
478
576
|
if (promptId) {
|
|
479
577
|
relay.watchReplies([promptId]);
|
|
480
|
-
|
|
481
|
-
if (current)
|
|
482
|
-
sessions.set(from, channel, { ...current, promptEventIds: [promptId] });
|
|
578
|
+
rememberPrompt(from, channel, { eventId: promptId, requests: result.inputRequests, asker });
|
|
483
579
|
}
|
|
484
580
|
log(`posted ${result.inputRequests.length} input request(s) in ${channel.slice(0, 8)}`);
|
|
485
581
|
startFollower(from, channel);
|
package/dist/cli.js
CHANGED
|
@@ -231,6 +231,7 @@ function run() {
|
|
|
231
231
|
? { agentWorkTimeoutMs: Number(process.env.BUZZ_AGENT_WORK_TIMEOUT_MS) }
|
|
232
232
|
: {}),
|
|
233
233
|
...(process.env.BUZZ_CARDS === "off" ? { cards: false } : {}),
|
|
234
|
+
...(process.env.BUZZ_ANSWERS === "anyone" ? { answers: "anyone" } : {}),
|
|
234
235
|
...(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS
|
|
235
236
|
? { agentSilenceTimeoutMs: Number(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS) }
|
|
236
237
|
: {}),
|
|
@@ -424,6 +425,7 @@ else {
|
|
|
424
425
|
BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum silence before a turn is acknowledged (default 300000ms)
|
|
425
426
|
BUZZ_AGENT_WORK_TIMEOUT_MS ceiling on a turn once acknowledged (default 3600000ms)
|
|
426
427
|
BUZZ_CARDS=off post replies without the cards tools handed back
|
|
428
|
+
BUZZ_ANSWERS=anyone let anyone in a channel answer a question (default: only who asked)
|
|
427
429
|
BUZZ_PROFILE_SYNC_MS how often the profile is re-checked against the name and
|
|
428
430
|
picture its owner set (default 900000ms, fifteen
|
|
429
431
|
minutes; 0 turns it off)
|
package/dist/hitl.d.ts
CHANGED
|
@@ -1,19 +1,87 @@
|
|
|
1
1
|
import { type ClientSession, type InputRequest, type InputResponse } from "eve/client";
|
|
2
|
-
import { type Card } from "@kybernesis/cards";
|
|
3
|
-
import type { StoredSession } from "./sessions.js";
|
|
2
|
+
import { type AskAnswer, type AskSettled, type Card } from "@kybernesis/cards";
|
|
3
|
+
import type { StoredAsker, StoredPrompt, StoredSession } from "./sessions.js";
|
|
4
|
+
import { type TurnFailure } from "./turn.js";
|
|
5
|
+
/** How a question is posted: who it is for, and which of its requests have ended. */
|
|
6
|
+
export interface PromptOptions {
|
|
7
|
+
/** Carry each question hidden after the words, for the plugin. */
|
|
8
|
+
carry?: boolean;
|
|
9
|
+
/** The person whose message raised it. */
|
|
10
|
+
asker?: StoredAsker;
|
|
11
|
+
/** Only the asker may answer it. */
|
|
12
|
+
askerOnly?: boolean;
|
|
13
|
+
/** Requests that have ended, by request id: shown as how they ended, with nothing to answer. */
|
|
14
|
+
settled?: Readonly<Record<string, AskSettled>>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Render pending Eve input in a Buzz channel.
|
|
18
|
+
*
|
|
19
|
+
* Everyone reads the words: a question's sentence (a `[kyb:…]` marker line is
|
|
20
|
+
* dropped), an approval as the email or event it would send, then the options
|
|
21
|
+
* by number. With `carry`, each question also travels hidden after the words
|
|
22
|
+
* (`<!-- kyb:ask v1 … -->`), so the Kybernesis plugin can draw it the way
|
|
23
|
+
* Studio does and answer it with a press. Only a posted prompt carries them:
|
|
24
|
+
* a press is accepted only as a reply to a prompt the bridge is watching.
|
|
25
|
+
*
|
|
26
|
+
* The same text, with `settled`, is what the prompt is edited to once a
|
|
27
|
+
* question ends: how it ended and who ended it, then what it asked, and the
|
|
28
|
+
* question hidden as `kyb:settled` so no client offers to answer it again.
|
|
29
|
+
*/
|
|
30
|
+
export declare function formatInputRequests(requests: readonly InputRequest[], options?: PromptOptions): string;
|
|
31
|
+
/** What someone other than the asker hears when they try to answer. */
|
|
32
|
+
export declare function notTheAskerReply(asker: StoredAsker): string;
|
|
33
|
+
/** Whether `pubkey`, resolved to `userId`, is the person a question is for. */
|
|
34
|
+
export declare function isAsker(asker: StoredAsker, pubkey: string, userId?: string): boolean;
|
|
35
|
+
/** How each response settles its request, said by `by`. */
|
|
36
|
+
export declare function settlementsFor(responses: readonly InputResponse[], requests: readonly InputRequest[], by?: string, at?: number): Map<string, AskSettled>;
|
|
37
|
+
/**
|
|
38
|
+
* Prompts with `settlements` applied: each one that changed, with the text it
|
|
39
|
+
* is edited to, and the prompts still waiting on something afterwards.
|
|
40
|
+
*/
|
|
41
|
+
export declare function applySettlements(prompts: readonly StoredPrompt[], settlements: ReadonlyMap<string, AskSettled>, options: {
|
|
42
|
+
carry?: boolean;
|
|
43
|
+
askerOnly?: boolean;
|
|
44
|
+
}): {
|
|
45
|
+
edits: {
|
|
46
|
+
eventId: string;
|
|
47
|
+
content: string;
|
|
48
|
+
}[];
|
|
49
|
+
open: StoredPrompt[];
|
|
50
|
+
};
|
|
51
|
+
/** Every request in `prompts` that has not ended yet, settled the same way. */
|
|
52
|
+
export declare function settleRemaining(prompts: readonly StoredPrompt[], settled: AskSettled): Map<string, AskSettled>;
|
|
4
53
|
/**
|
|
5
|
-
*
|
|
54
|
+
* Answers a client pressed, checked against what is actually open.
|
|
6
55
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
56
|
+
* Each answer must name an open request. An option must be one of that
|
|
57
|
+
* request's own; words alone answer only a question that takes words. eve's
|
|
58
|
+
* strict schema checks the result before it is sent. `stale` is true when no
|
|
59
|
+
* answer named an open request, which is what a second press on a question
|
|
60
|
+
* someone already answered looks like.
|
|
9
61
|
*/
|
|
10
|
-
export declare function
|
|
62
|
+
export declare function resolveCarriedAnswers(answers: readonly AskAnswer[], requests: readonly InputRequest[]): {
|
|
63
|
+
responses: readonly InputResponse[];
|
|
64
|
+
stale: false;
|
|
65
|
+
} | {
|
|
66
|
+
responses: null;
|
|
67
|
+
stale: boolean;
|
|
68
|
+
};
|
|
69
|
+
/** What a press on a question nobody is waiting on any more gets. */
|
|
70
|
+
export declare const STALE_ANSWER_REPLY = "That question was already answered, so nothing changed.";
|
|
11
71
|
/** Resolve a Buzz text reply using Eve's certified matching semantics. */
|
|
12
72
|
export declare function resolveInputReply(text: string, requests: readonly InputRequest[]): readonly InputResponse[] | null;
|
|
13
73
|
/** Actionable response for text that cannot satisfy every pending request. */
|
|
14
74
|
export declare function invalidInputReply(requests: readonly InputRequest[]): string;
|
|
15
|
-
/**
|
|
16
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Submit a matched reply without consuming its response stream.
|
|
77
|
+
*
|
|
78
|
+
* No `clientContext`: the turn already carries the channel from when it
|
|
79
|
+
* started, and eve 0.81 treats context on a respond as new turn input. It then
|
|
80
|
+
* runs one more model step after the agent's reply, with the agent's message
|
|
81
|
+
* last, which models that refuse prefill reject. The turn fails and the whole
|
|
82
|
+
* conversation ends, on an approve, a decline or an answer alike.
|
|
83
|
+
*/
|
|
84
|
+
export declare function respondToPendingConversation(session: Pick<ClientSession, "respond">, responses: readonly InputResponse[]): Promise<void>;
|
|
17
85
|
/**
|
|
18
86
|
* How long a follower waits between reattaching when it saw no progress.
|
|
19
87
|
*
|
|
@@ -34,6 +102,16 @@ export declare function followerRetryDelayMs(cycles: number, capMs?: number): nu
|
|
|
34
102
|
* Say the answer arrived, say the work did not, and say the channel is usable.
|
|
35
103
|
*/
|
|
36
104
|
export declare function resumeStalledReply(stallMs: number): string;
|
|
105
|
+
/**
|
|
106
|
+
* What the room is told when the turn an answer resumed failed.
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* Found live: a declined approval resumed the turn, the model refused the
|
|
110
|
+
* continuation, and the run ended. The follower saw the stream end and said
|
|
111
|
+
* nothing, so the person who pressed Decline got silence. They answered; the
|
|
112
|
+
* failure is the one thing they must hear about, in the stream's own words.
|
|
113
|
+
*/
|
|
114
|
+
export declare function resumeFailedReply(failures: readonly TurnFailure[]): string;
|
|
37
115
|
/** A session needs its follower while asking or while resumed output is unpublished. */
|
|
38
116
|
export declare function needsPendingFollower(session: Pick<StoredSession, "pendingInputRequests" | "resumeInFlight">): boolean;
|
|
39
117
|
export interface PendingFollowerCallbacks {
|
|
@@ -42,6 +120,8 @@ export interface PendingFollowerCallbacks {
|
|
|
42
120
|
onInputRequested(requests: readonly InputRequest[]): void | Promise<void>;
|
|
43
121
|
/** The resumed turn's words, and the cards its tools handed back. */
|
|
44
122
|
onMessage(message: string, cards: readonly Card[]): void | Promise<void>;
|
|
123
|
+
/** The resumed turn failed: what the stream said, for the room. */
|
|
124
|
+
onFailed?(failures: readonly TurnFailure[]): void | Promise<void>;
|
|
45
125
|
onLog?: (message: string) => void;
|
|
46
126
|
}
|
|
47
127
|
type MutableStoredSession = Omit<StoredSession, "updated">;
|
package/dist/hitl.js
CHANGED
|
@@ -1,32 +1,169 @@
|
|
|
1
|
-
import { resolveTextToResponses, } from "eve/client";
|
|
2
|
-
import { cardsOf,
|
|
1
|
+
import { parseInputResponses, resolveTextToResponses, } from "eve/client";
|
|
2
|
+
import { askFrom, askText, cardsOf, hiddenAsk, hiddenSettled, newStepState, readStep, settledLine, settleWith, } from "@kybernesis/cards";
|
|
3
|
+
import { failureFromEvent, silentTurnReply } from "./turn.js";
|
|
3
4
|
/**
|
|
4
|
-
* Render pending Eve input in a
|
|
5
|
+
* Render pending Eve input in a Buzz channel.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* Everyone reads the words: a question's sentence (a `[kyb:…]` marker line is
|
|
8
|
+
* dropped), an approval as the email or event it would send, then the options
|
|
9
|
+
* by number. With `carry`, each question also travels hidden after the words
|
|
10
|
+
* (`<!-- kyb:ask v1 … -->`), so the Kybernesis plugin can draw it the way
|
|
11
|
+
* Studio does and answer it with a press. Only a posted prompt carries them:
|
|
12
|
+
* a press is accepted only as a reply to a prompt the bridge is watching.
|
|
13
|
+
*
|
|
14
|
+
* The same text, with `settled`, is what the prompt is edited to once a
|
|
15
|
+
* question ends: how it ended and who ended it, then what it asked, and the
|
|
16
|
+
* question hidden as `kyb:settled` so no client offers to answer it again.
|
|
8
17
|
*/
|
|
9
|
-
export function formatInputRequests(requests) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
export function formatInputRequests(requests, options = {}) {
|
|
19
|
+
const person = options.asker ? { key: options.asker.key, ...(options.asker.name ? { name: options.asker.name } : {}) } : undefined;
|
|
20
|
+
const only = Boolean(options.askerOnly && person);
|
|
21
|
+
const asks = requests.map((request) => ({
|
|
22
|
+
...askFrom(request),
|
|
23
|
+
...(person ? { asker: person } : {}),
|
|
24
|
+
...(only ? { askerOnly: true } : {}),
|
|
25
|
+
}));
|
|
26
|
+
const settledOf = (ask) => options.settled?.[ask.requestId];
|
|
27
|
+
const text = asks.map((ask, requestIndex) => {
|
|
28
|
+
const request = requests[requestIndex];
|
|
29
|
+
const prompt = askText(ask);
|
|
30
|
+
const numbered = (line) => (requests.length > 1 ? `Question ${requestIndex + 1}: ${line}` : line);
|
|
31
|
+
const settled = settledOf(ask);
|
|
32
|
+
if (settled)
|
|
33
|
+
return [numbered(settledLine({ ...ask, settled })), "", prompt].join("\n");
|
|
34
|
+
const lines = [numbered(prompt)];
|
|
15
35
|
if (request.options?.length) {
|
|
16
36
|
lines.push("", ...request.options.map((option, optionIndex) => {
|
|
17
37
|
const description = option.description ? ` — ${option.description}` : "";
|
|
18
38
|
return `${optionIndex + 1}. ${option.label} [${option.id}]${description}`;
|
|
19
39
|
}));
|
|
20
40
|
}
|
|
41
|
+
const who = only && person?.name ? `${person.name}, reply` : "Reply";
|
|
21
42
|
if (request.allowFreeform === true || !request.options?.length) {
|
|
22
|
-
lines.push("",
|
|
43
|
+
lines.push("", `${who} to this message with an option number, option ID, exact label, or your own answer.`);
|
|
23
44
|
}
|
|
24
45
|
else {
|
|
25
|
-
lines.push("",
|
|
46
|
+
lines.push("", `${who} to this message with an option number, option ID, or exact label.`);
|
|
26
47
|
}
|
|
27
48
|
return lines.join("\n");
|
|
28
49
|
}).join("\n\n");
|
|
50
|
+
if (!options.carry)
|
|
51
|
+
return text;
|
|
52
|
+
const hidden = asks
|
|
53
|
+
.map((ask) => {
|
|
54
|
+
const settled = settledOf(ask);
|
|
55
|
+
return settled ? hiddenSettled({ ...ask, settled }) : hiddenAsk(ask);
|
|
56
|
+
})
|
|
57
|
+
.filter((h) => h !== null);
|
|
58
|
+
return hidden.length ? `${text}\n\n${hidden.join("\n")}` : text;
|
|
59
|
+
}
|
|
60
|
+
/** What someone other than the asker hears when they try to answer. */
|
|
61
|
+
export function notTheAskerReply(asker) {
|
|
62
|
+
return `Only ${asker.name ?? "the person who asked"} can answer this one. I can take something new once it's answered.`;
|
|
63
|
+
}
|
|
64
|
+
/** Whether `pubkey`, resolved to `userId`, is the person a question is for. */
|
|
65
|
+
export function isAsker(asker, pubkey, userId) {
|
|
66
|
+
return asker.key === pubkey || Boolean(asker.userId && userId && asker.userId === userId);
|
|
67
|
+
}
|
|
68
|
+
/** How each response settles its request, said by `by`. */
|
|
69
|
+
export function settlementsFor(responses, requests, by, at = Date.now()) {
|
|
70
|
+
const open = new Map(requests.map((request) => [request.requestId, request]));
|
|
71
|
+
const out = new Map();
|
|
72
|
+
for (const response of responses) {
|
|
73
|
+
const request = open.get(response.requestId);
|
|
74
|
+
if (!request)
|
|
75
|
+
continue;
|
|
76
|
+
const answer = {
|
|
77
|
+
...(response.optionId !== undefined ? { optionId: response.optionId } : {}),
|
|
78
|
+
...(response.text !== undefined ? { text: response.text } : {}),
|
|
79
|
+
};
|
|
80
|
+
out.set(response.requestId, settleWith(askFrom(request), answer, by, at));
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Prompts with `settlements` applied: each one that changed, with the text it
|
|
86
|
+
* is edited to, and the prompts still waiting on something afterwards.
|
|
87
|
+
*/
|
|
88
|
+
export function applySettlements(prompts, settlements, options) {
|
|
89
|
+
const edits = [];
|
|
90
|
+
const open = [];
|
|
91
|
+
for (const prompt of prompts) {
|
|
92
|
+
const fresh = prompt.requests.filter((request) => settlements.has(request.requestId) && !prompt.settled?.[request.requestId]);
|
|
93
|
+
let next = prompt;
|
|
94
|
+
if (fresh.length) {
|
|
95
|
+
const settled = { ...prompt.settled };
|
|
96
|
+
for (const request of fresh)
|
|
97
|
+
settled[request.requestId] = settlements.get(request.requestId);
|
|
98
|
+
next = { ...prompt, settled };
|
|
99
|
+
edits.push({
|
|
100
|
+
eventId: prompt.eventId,
|
|
101
|
+
content: formatInputRequests(prompt.requests, { carry: options.carry, asker: prompt.asker, askerOnly: options.askerOnly, settled }),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (next.requests.some((request) => !next.settled?.[request.requestId]))
|
|
105
|
+
open.push(next);
|
|
106
|
+
}
|
|
107
|
+
return { edits, open };
|
|
108
|
+
}
|
|
109
|
+
/** Every request in `prompts` that has not ended yet, settled the same way. */
|
|
110
|
+
export function settleRemaining(prompts, settled) {
|
|
111
|
+
const out = new Map();
|
|
112
|
+
for (const prompt of prompts) {
|
|
113
|
+
for (const request of prompt.requests) {
|
|
114
|
+
if (!prompt.settled?.[request.requestId])
|
|
115
|
+
out.set(request.requestId, settled);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Answers a client pressed, checked against what is actually open.
|
|
122
|
+
*
|
|
123
|
+
* Each answer must name an open request. An option must be one of that
|
|
124
|
+
* request's own; words alone answer only a question that takes words. eve's
|
|
125
|
+
* strict schema checks the result before it is sent. `stale` is true when no
|
|
126
|
+
* answer named an open request, which is what a second press on a question
|
|
127
|
+
* someone already answered looks like.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveCarriedAnswers(answers, requests) {
|
|
130
|
+
const open = new Map(requests.map((request) => [request.requestId, request]));
|
|
131
|
+
const picked = new Map();
|
|
132
|
+
let named = false;
|
|
133
|
+
for (const answer of answers) {
|
|
134
|
+
const request = open.get(answer.requestId);
|
|
135
|
+
if (!request)
|
|
136
|
+
continue;
|
|
137
|
+
named = true;
|
|
138
|
+
if (picked.has(request.requestId))
|
|
139
|
+
continue;
|
|
140
|
+
const ids = request.options?.map((option) => option.id) ?? [];
|
|
141
|
+
if (answer.optionId !== undefined) {
|
|
142
|
+
if (ids.length > 0 && !ids.includes(answer.optionId))
|
|
143
|
+
return { responses: null, stale: false };
|
|
144
|
+
picked.set(request.requestId, {
|
|
145
|
+
requestId: request.requestId,
|
|
146
|
+
optionId: answer.optionId,
|
|
147
|
+
...(answer.text?.trim() ? { text: answer.text.trim() } : {}),
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const words = answer.text?.trim();
|
|
152
|
+
if (!words || (ids.length > 0 && request.allowFreeform !== true))
|
|
153
|
+
return { responses: null, stale: false };
|
|
154
|
+
picked.set(request.requestId, { requestId: request.requestId, text: words });
|
|
155
|
+
}
|
|
156
|
+
if (picked.size === 0)
|
|
157
|
+
return { responses: null, stale: !named };
|
|
158
|
+
try {
|
|
159
|
+
return { responses: parseInputResponses([...picked.values()]), stale: false };
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return { responses: null, stale: false };
|
|
163
|
+
}
|
|
29
164
|
}
|
|
165
|
+
/** What a press on a question nobody is waiting on any more gets. */
|
|
166
|
+
export const STALE_ANSWER_REPLY = "That question was already answered, so nothing changed.";
|
|
30
167
|
/** Resolve a Buzz text reply using Eve's certified matching semantics. */
|
|
31
168
|
export function resolveInputReply(text, requests) {
|
|
32
169
|
if (!text.trim())
|
|
@@ -38,10 +175,17 @@ export function resolveInputReply(text, requests) {
|
|
|
38
175
|
export function invalidInputReply(requests) {
|
|
39
176
|
return `I still need a valid answer before I can continue.\n\n${formatInputRequests(requests)}`;
|
|
40
177
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Submit a matched reply without consuming its response stream.
|
|
180
|
+
*
|
|
181
|
+
* No `clientContext`: the turn already carries the channel from when it
|
|
182
|
+
* started, and eve 0.81 treats context on a respond as new turn input. It then
|
|
183
|
+
* runs one more model step after the agent's reply, with the agent's message
|
|
184
|
+
* last, which models that refuse prefill reject. The turn fails and the whole
|
|
185
|
+
* conversation ends, on an approve, a decline or an answer alike.
|
|
186
|
+
*/
|
|
187
|
+
export async function respondToPendingConversation(session, responses) {
|
|
43
188
|
await session.respond(responses, {
|
|
44
|
-
clientContext: { buzzCommunity: community, buzzChannel: channel },
|
|
45
189
|
streamReconnectPolicy: { reconnect: false },
|
|
46
190
|
});
|
|
47
191
|
}
|
|
@@ -73,6 +217,19 @@ export function resumeStalledReply(stallMs) {
|
|
|
73
217
|
`${minutes === 1 ? "" : "s"} without producing anything, so I have stopped waiting on it. ` +
|
|
74
218
|
`Nothing was posted and no work landed from it. This channel is clear — ask me again and I will start fresh.`);
|
|
75
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* What the room is told when the turn an answer resumed failed.
|
|
222
|
+
*
|
|
223
|
+
* @remarks
|
|
224
|
+
* Found live: a declined approval resumed the turn, the model refused the
|
|
225
|
+
* continuation, and the run ended. The follower saw the stream end and said
|
|
226
|
+
* nothing, so the person who pressed Decline got silence. They answered; the
|
|
227
|
+
* failure is the one thing they must hear about, in the stream's own words.
|
|
228
|
+
*/
|
|
229
|
+
export function resumeFailedReply(failures) {
|
|
230
|
+
const said = silentTurnReply(failures);
|
|
231
|
+
return `Your answer reached me, but the work it restarted stopped. ${said}`;
|
|
232
|
+
}
|
|
76
233
|
/** A session needs its follower while asking or while resumed output is unpublished. */
|
|
77
234
|
export function needsPendingFollower(session) {
|
|
78
235
|
return Boolean(session.pendingInputRequests?.length || session.resumeInFlight);
|
|
@@ -106,6 +263,16 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
106
263
|
let streamIndex = initial.streamIndex;
|
|
107
264
|
let completedMessage = "";
|
|
108
265
|
let steps = newStepState();
|
|
266
|
+
/** Failures since the last answer. A declined call is the person's choice, not one. */
|
|
267
|
+
let failures = [];
|
|
268
|
+
/** Tell the room, then stop supervising: a failed run will not come back. */
|
|
269
|
+
const failThenClear = async () => {
|
|
270
|
+
// Only an answered question resumed this work; a run failing while it waits on a person stays quiet as before.
|
|
271
|
+
if (state.resumeInFlight)
|
|
272
|
+
await callbacks.onFailed?.(failures);
|
|
273
|
+
state = withoutSupervision(state);
|
|
274
|
+
await persist();
|
|
275
|
+
};
|
|
109
276
|
const persist = async () => {
|
|
110
277
|
state = { ...state, streamIndex };
|
|
111
278
|
await callbacks.onState(state);
|
|
@@ -133,10 +300,15 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
133
300
|
// the follower is still open.
|
|
134
301
|
streamIndex += 1;
|
|
135
302
|
readStep(steps, streamEvent.type, streamEvent.data);
|
|
303
|
+
const declined = streamEvent.type === "action.result" && streamEvent.data.status === "rejected";
|
|
304
|
+
const failure = declined ? null : failureFromEvent(streamEvent);
|
|
305
|
+
if (failure)
|
|
306
|
+
failures.push(failure);
|
|
136
307
|
switch (streamEvent.type) {
|
|
137
308
|
case "input.requested":
|
|
138
309
|
completedMessage = "";
|
|
139
310
|
steps = newStepState();
|
|
311
|
+
failures = [];
|
|
140
312
|
state = withPending(state, streamEvent.data.requests);
|
|
141
313
|
// Keep the durable cursor before the prompt until Buzz accepts it for
|
|
142
314
|
// relay delivery; a reconnect can then replay and render the request.
|
|
@@ -169,9 +341,11 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
169
341
|
}
|
|
170
342
|
await persist();
|
|
171
343
|
break;
|
|
172
|
-
case "turn.cancelled":
|
|
173
344
|
case "turn.failed":
|
|
174
345
|
case "session.failed":
|
|
346
|
+
await failThenClear();
|
|
347
|
+
return;
|
|
348
|
+
case "turn.cancelled":
|
|
175
349
|
case "session.completed":
|
|
176
350
|
state = withoutSupervision(state);
|
|
177
351
|
await persist();
|
|
@@ -183,5 +357,16 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
183
357
|
break;
|
|
184
358
|
}
|
|
185
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* The stream closed without a boundary. Usually a dropped connection, and
|
|
362
|
+
* the caller reattaches. But after an answer, a model failure followed by
|
|
363
|
+
* the end of the stream is the run ending: reattaching finds nothing new,
|
|
364
|
+
* and the room would be told only after the work ceiling, an hour later.
|
|
365
|
+
*/
|
|
366
|
+
if (state.resumeInFlight && failures.some((f) => f.kind === "model")) {
|
|
367
|
+
callbacks.onLog?.("resumed turn failed and its stream ended; telling the room");
|
|
368
|
+
await failThenClear();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
186
371
|
callbacks.onLog?.("pending Eve stream ended before a resumable boundary");
|
|
187
372
|
}
|
package/dist/relay.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ import type { AgentKey } from "./keys.js";
|
|
|
15
15
|
export declare const KIND_MESSAGE = 9;
|
|
16
16
|
/** NIP-25 reaction. */
|
|
17
17
|
export declare const KIND_REACTION = 7;
|
|
18
|
+
/** A replacement edit of a message, by its author only (the relay enforces it). */
|
|
19
|
+
export declare const KIND_EDIT = 40003;
|
|
18
20
|
/** NIP-42 relay authentication. */
|
|
19
21
|
export declare const KIND_AUTH = 22242;
|
|
20
22
|
/** NIP-98 HTTP authentication. */
|
|
@@ -37,6 +39,9 @@ export declare const TYPING_INTERVAL_MS = 3000;
|
|
|
37
39
|
export declare const PRESENCE_INTERVAL_MS = 60000;
|
|
38
40
|
/** Enough overlap for same-second redelivery without retaining every event forever. */
|
|
39
41
|
export declare const MAX_SEEN_EVENT_IDS = 10000;
|
|
42
|
+
/** At most this many messages wait for a reconnect, and none older than this goes out late. */
|
|
43
|
+
export declare const MAX_OUTBOX = 100;
|
|
44
|
+
export declare const OUTBOX_MAX_AGE_MS: number;
|
|
40
45
|
/** How often to re-read which direct conversations this agent is in. */
|
|
41
46
|
export declare const DIRECT_REFRESH_MS = 30000;
|
|
42
47
|
export type NostrEvent = {
|
|
@@ -69,6 +74,14 @@ export declare class BuzzRelay {
|
|
|
69
74
|
private refusal;
|
|
70
75
|
/** Answered already, so a redelivery in two overlapping polls is not answered twice. */
|
|
71
76
|
private readonly seen;
|
|
77
|
+
/**
|
|
78
|
+
* Messages and reactions signed while the connection was down or not yet signed in.
|
|
79
|
+
*
|
|
80
|
+
* The relay drops in and out every few minutes. A question posted in one of those gaps
|
|
81
|
+
* used to vanish: the person never saw it, and their next message was taken as a wrong
|
|
82
|
+
* answer to it. They go out, in order, as soon as the connection is signed in again.
|
|
83
|
+
*/
|
|
84
|
+
private readonly outbox;
|
|
72
85
|
private cursor;
|
|
73
86
|
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
74
87
|
private readonly replyWatch;
|
|
@@ -134,12 +147,16 @@ export declare class BuzzRelay {
|
|
|
134
147
|
private discoveryDone;
|
|
135
148
|
/** Take the DMs just found; read back any that are new, from when they could last have been missed. */
|
|
136
149
|
private adopt;
|
|
137
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Publish one event; returns its id when it left this process or waits in the outbox, null
|
|
152
|
+
* when it was dropped. Presence and typing are dropped while offline: late, they are wrong.
|
|
153
|
+
*/
|
|
138
154
|
publish(event: {
|
|
139
155
|
kind: number;
|
|
140
156
|
tags: string[][];
|
|
141
157
|
content: string;
|
|
142
158
|
}): string | null;
|
|
159
|
+
private flushOutbox;
|
|
143
160
|
setPresence(status: PresenceStatus): void;
|
|
144
161
|
private keepPresent;
|
|
145
162
|
/** Say "typing" in a channel until the returned function is called. */
|
|
@@ -148,6 +165,13 @@ export declare class BuzzRelay {
|
|
|
148
165
|
reply(channel: string, text: string, replyTo?: NostrEvent, options?: {
|
|
149
166
|
mention?: boolean;
|
|
150
167
|
}): string | null;
|
|
168
|
+
/**
|
|
169
|
+
* Replace the words of a message this agent posted. Every client shows the
|
|
170
|
+
* latest edit by the author, and only the author's edits count, so nobody
|
|
171
|
+
* else can make a message of the agent's say something else. No mention
|
|
172
|
+
* tag: an edit is not news, so nobody is notified again.
|
|
173
|
+
*/
|
|
174
|
+
edit(channel: string, eventId: string, text: string): string | null;
|
|
151
175
|
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
152
176
|
private httpAuth;
|
|
153
177
|
private submit;
|
package/dist/relay.js
CHANGED
|
@@ -16,6 +16,8 @@ import { finalizeEvent } from "nostr-tools/pure";
|
|
|
16
16
|
export const KIND_MESSAGE = 9;
|
|
17
17
|
/** NIP-25 reaction. */
|
|
18
18
|
export const KIND_REACTION = 7;
|
|
19
|
+
/** A replacement edit of a message, by its author only (the relay enforces it). */
|
|
20
|
+
export const KIND_EDIT = 40003;
|
|
19
21
|
/** NIP-42 relay authentication. */
|
|
20
22
|
export const KIND_AUTH = 22242;
|
|
21
23
|
/** NIP-98 HTTP authentication. */
|
|
@@ -38,6 +40,9 @@ export const TYPING_INTERVAL_MS = 3_000;
|
|
|
38
40
|
export const PRESENCE_INTERVAL_MS = 60_000;
|
|
39
41
|
/** Enough overlap for same-second redelivery without retaining every event forever. */
|
|
40
42
|
export const MAX_SEEN_EVENT_IDS = 10_000;
|
|
43
|
+
/** At most this many messages wait for a reconnect, and none older than this goes out late. */
|
|
44
|
+
export const MAX_OUTBOX = 100;
|
|
45
|
+
export const OUTBOX_MAX_AGE_MS = 15 * 60_000;
|
|
41
46
|
/** How often to re-read which direct conversations this agent is in. */
|
|
42
47
|
export const DIRECT_REFRESH_MS = 30_000;
|
|
43
48
|
/** Ids per `#d`/`#h` filter, and filters per request: the relay caps a request at ten. */
|
|
@@ -68,6 +73,14 @@ export class BuzzRelay {
|
|
|
68
73
|
refusal = null;
|
|
69
74
|
/** Answered already, so a redelivery in two overlapping polls is not answered twice. */
|
|
70
75
|
seen = new Set();
|
|
76
|
+
/**
|
|
77
|
+
* Messages and reactions signed while the connection was down or not yet signed in.
|
|
78
|
+
*
|
|
79
|
+
* The relay drops in and out every few minutes. A question posted in one of those gaps
|
|
80
|
+
* used to vanish: the person never saw it, and their next message was taken as a wrong
|
|
81
|
+
* answer to it. They go out, in order, as soon as the connection is signed in again.
|
|
82
|
+
*/
|
|
83
|
+
outbox = [];
|
|
71
84
|
cursor = now();
|
|
72
85
|
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
73
86
|
replyWatch = new Set();
|
|
@@ -229,6 +242,7 @@ export class BuzzRelay {
|
|
|
229
242
|
this.backoff = 5_000;
|
|
230
243
|
this.refusal = null;
|
|
231
244
|
this.log(`authenticated as ${this.key.publicKey.slice(0, 12)}…`);
|
|
245
|
+
this.flushOutbox();
|
|
232
246
|
this.keepPresent();
|
|
233
247
|
this.discoverDirect();
|
|
234
248
|
this.poll();
|
|
@@ -404,10 +418,38 @@ export class BuzzRelay {
|
|
|
404
418
|
this.send(["REQ", `m${Date.now()}c`, ...filters]);
|
|
405
419
|
}
|
|
406
420
|
}
|
|
407
|
-
/**
|
|
421
|
+
/**
|
|
422
|
+
* Publish one event; returns its id when it left this process or waits in the outbox, null
|
|
423
|
+
* when it was dropped. Presence and typing are dropped while offline: late, they are wrong.
|
|
424
|
+
*/
|
|
408
425
|
publish(event) {
|
|
409
426
|
const signed = finalizeEvent({ created_at: now(), ...event }, this.key.secretKey);
|
|
410
|
-
|
|
427
|
+
if (this.authenticated && this.send(["EVENT", signed]))
|
|
428
|
+
return signed.id;
|
|
429
|
+
// Held only between connections of a running relay; one never started or closed sends nothing.
|
|
430
|
+
if (this.closed || !this.socket || ![KIND_MESSAGE, KIND_REACTION, KIND_EDIT].includes(event.kind))
|
|
431
|
+
return null;
|
|
432
|
+
if (this.outbox.length >= MAX_OUTBOX)
|
|
433
|
+
this.outbox.shift();
|
|
434
|
+
this.outbox.push({ event: signed, at: Date.now() });
|
|
435
|
+
return signed.id;
|
|
436
|
+
}
|
|
437
|
+
flushOutbox() {
|
|
438
|
+
const cutoff = Date.now() - OUTBOX_MAX_AGE_MS;
|
|
439
|
+
const stale = this.outbox.filter(({ at }) => at < cutoff).length;
|
|
440
|
+
if (stale)
|
|
441
|
+
this.log(`dropped ${stale} message(s) held too long while disconnected`);
|
|
442
|
+
const due = this.outbox.splice(0).filter(({ at }) => at >= cutoff);
|
|
443
|
+
let sent = 0;
|
|
444
|
+
for (const held of due) {
|
|
445
|
+
if (!this.send(["EVENT", held.event]))
|
|
446
|
+
break;
|
|
447
|
+
sent += 1;
|
|
448
|
+
}
|
|
449
|
+
// The connection dropped again mid-flush: the rest wait for the next sign-in, in order.
|
|
450
|
+
this.outbox.unshift(...due.slice(sent));
|
|
451
|
+
if (sent)
|
|
452
|
+
this.log(`sent ${sent} message(s) held while disconnected`);
|
|
411
453
|
}
|
|
412
454
|
setPresence(status) {
|
|
413
455
|
this.publish({ kind: KIND_PRESENCE, tags: [], content: status });
|
|
@@ -435,6 +477,15 @@ export class BuzzRelay {
|
|
|
435
477
|
}
|
|
436
478
|
return this.publish({ kind: KIND_MESSAGE, tags, content: text });
|
|
437
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* Replace the words of a message this agent posted. Every client shows the
|
|
482
|
+
* latest edit by the author, and only the author's edits count, so nobody
|
|
483
|
+
* else can make a message of the agent's say something else. No mention
|
|
484
|
+
* tag: an edit is not news, so nobody is notified again.
|
|
485
|
+
*/
|
|
486
|
+
edit(channel, eventId, text) {
|
|
487
|
+
return this.publish({ kind: KIND_EDIT, tags: [["h", channel], ["e", eventId]], content: text });
|
|
488
|
+
}
|
|
438
489
|
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
439
490
|
httpAuth(url, body) {
|
|
440
491
|
const auth = finalizeEvent({
|
package/dist/sessions.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
+
import type { AskSettled } from "@kybernesis/cards";
|
|
1
2
|
import type { InputRequest } from "eve/client";
|
|
3
|
+
/** The person whose message raised a question: their chat key, account and name. */
|
|
4
|
+
export interface StoredAsker {
|
|
5
|
+
key: string;
|
|
6
|
+
/** Their control-plane account, so another device of theirs counts as them. */
|
|
7
|
+
userId?: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
}
|
|
10
|
+
/** A question the bridge posted, kept until every request in it has settled, so it can be rewritten. */
|
|
11
|
+
export interface StoredPrompt {
|
|
12
|
+
eventId: string;
|
|
13
|
+
requests: readonly InputRequest[];
|
|
14
|
+
asker?: StoredAsker;
|
|
15
|
+
/** Requests that have ended, by request id. */
|
|
16
|
+
settled?: Readonly<Record<string, AskSettled>>;
|
|
17
|
+
}
|
|
2
18
|
/** One conversation: which eve session it is, and how far the bridge has read. */
|
|
3
19
|
export interface StoredSession {
|
|
4
20
|
id: string;
|
|
@@ -14,6 +30,10 @@ export interface StoredSession {
|
|
|
14
30
|
* counts as an answer whether or not it mentions this agent.
|
|
15
31
|
*/
|
|
16
32
|
promptEventIds?: readonly string[];
|
|
33
|
+
/** Who the open questions are for. Absent on conversations from before 0.15: anyone may answer those. */
|
|
34
|
+
asker?: StoredAsker;
|
|
35
|
+
/** The posted questions not yet fully settled, so the bridge can rewrite each one when it ends. */
|
|
36
|
+
prompts?: readonly StoredPrompt[];
|
|
17
37
|
/** When it was last used, so a store that runs for years does not grow forever. */
|
|
18
38
|
updated: number;
|
|
19
39
|
}
|
package/dist/turn.d.ts
CHANGED
|
@@ -98,9 +98,10 @@ export declare const DEFAULT_UNREAD_DRAIN_TIMEOUT_MS = 20000;
|
|
|
98
98
|
export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void, agentSilenceTimeoutMs?: number, agentWorkTimeoutMs?: number, unreadDrainTimeoutMs?: number): Promise<TurnOutcome>;
|
|
99
99
|
/** What the room is told when eve refuses the turn itself. */
|
|
100
100
|
/**
|
|
101
|
-
* The
|
|
102
|
-
* turn: an unknown session id (404),
|
|
103
|
-
* `session_not_active`, "The session is no longer active.")
|
|
101
|
+
* The answers from eve that mean the stored session cannot carry another
|
|
102
|
+
* turn: an unknown session id (404), a session that ended (409 with
|
|
103
|
+
* `session_not_active`, "The session is no longer active."), and one whose
|
|
104
|
+
* history expired (410 `session_expired`). A session ends
|
|
104
105
|
* when one of its turns fails hard, for instance a model call that 404s at
|
|
105
106
|
* the gateway; the channel must then start a fresh one.
|
|
106
107
|
*
|
package/dist/turn.js
CHANGED
|
@@ -430,9 +430,10 @@ export async function answerTurn(client, sessions, channel, message, community,
|
|
|
430
430
|
}
|
|
431
431
|
/** What the room is told when eve refuses the turn itself. */
|
|
432
432
|
/**
|
|
433
|
-
* The
|
|
434
|
-
* turn: an unknown session id (404),
|
|
435
|
-
* `session_not_active`, "The session is no longer active.")
|
|
433
|
+
* The answers from eve that mean the stored session cannot carry another
|
|
434
|
+
* turn: an unknown session id (404), a session that ended (409 with
|
|
435
|
+
* `session_not_active`, "The session is no longer active."), and one whose
|
|
436
|
+
* history expired (410 `session_expired`). A session ends
|
|
436
437
|
* when one of its turns fails hard, for instance a model call that 404s at
|
|
437
438
|
* the gateway; the channel must then start a fresh one.
|
|
438
439
|
*
|
|
@@ -446,7 +447,10 @@ export async function answerTurn(client, sessions, channel, message, community,
|
|
|
446
447
|
export function isSessionGone(error) {
|
|
447
448
|
if (!(error instanceof ClientError))
|
|
448
449
|
return false;
|
|
449
|
-
|
|
450
|
+
// 410 session_expired, "Session history is no longer available.": a run that
|
|
451
|
+
// ended (a failed turn ends the session) can no longer be read. Seen on
|
|
452
|
+
// 2026-10-24 when the next message in a channel failed instead of starting over.
|
|
453
|
+
if (error.status === 404 || error.status === 410)
|
|
450
454
|
return true;
|
|
451
455
|
return (error.status === 409 &&
|
|
452
456
|
(error.code === "session_not_active" || /no longer active/i.test(String(error.message ?? ""))));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Put an eve agent in a Buzz workspace as a member: per-speaker verified identity resolved through the control plane, self-service identity linking, presence, typing and seen signals.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"test": "node --test \"test/**/*.test.mjs\""
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@kybernesis/cards": "^0.
|
|
47
|
+
"@kybernesis/cards": "^0.6.0",
|
|
48
48
|
"@kybernesis/enterprise": "^0.10.0",
|
|
49
49
|
"nostr-tools": "^2.10.0",
|
|
50
50
|
"zod": "^4"
|