@kybernesis/buzz 0.12.2 → 0.14.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 +33 -0
- package/dist/bridge.d.ts +8 -0
- package/dist/bridge.js +116 -13
- package/dist/cli.js +2 -0
- package/dist/hitl.d.ts +54 -7
- package/dist/hitl.js +124 -14
- package/dist/relay.d.ts +20 -2
- package/dist/relay.js +47 -4
- package/dist/turn.d.ts +7 -3
- package/dist/turn.js +13 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -81,6 +81,38 @@ command on the host. The `.agent` name comes from `ARP_AGENT_DID`, or from
|
|
|
81
81
|
runs it through `@kybernesis/manage` (`buzz.profile`). `kybernesis-buzz profile --from-agentid`
|
|
82
82
|
still takes the profile from the name alone.
|
|
83
83
|
|
|
84
|
+
## Cards
|
|
85
|
+
|
|
86
|
+
When a tool hands back a card (a chart, a table, a checklist, an artifact), the bridge posts it
|
|
87
|
+
after the reply as its own threaded message: the card's text for people to read, and the card
|
|
88
|
+
itself in an HTML comment (`<!-- kyb:card v1 {...} -->`) that Buzz hides. The reply stays an
|
|
89
|
+
ordinary message Buzz draws itself. The Kybernesis cards plugin for the Buzz desktop app
|
|
90
|
+
(`plugin/`) draws those messages as the card; without it people read the text.
|
|
91
|
+
|
|
92
|
+
An artifact posted into a channel is shared with the channel: the bridge asks the agent for a
|
|
93
|
+
share link as the person who asked (`POST /eve/v1/kyb/artifacts/:id/share`, `@kybernesis/manage`
|
|
94
|
+
0.28), and the card carries it. The plugin draws the page inside the card on the macOS desktop
|
|
95
|
+
app and opens it beside the conversation; everywhere else the link is in the text. The person
|
|
96
|
+
can withdraw the link from the agent. `BUZZ_CARDS=off` stops card messages.
|
|
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. Anyone can answer 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
|
+
If the work an answer restarts fails (a model refusing the continuation, say), the bridge says so
|
|
113
|
+
in the channel with the stream's own error, rather than leaving the person who answered with
|
|
114
|
+
silence. A declined call is the person's choice and is not reported as a failure.
|
|
115
|
+
|
|
84
116
|
## Configuration
|
|
85
117
|
|
|
86
118
|
| Variable | Meaning |
|
|
@@ -91,6 +123,7 @@ still takes the profile from the name alone.
|
|
|
91
123
|
| `BUZZ_SESSIONS_FILE` | Channel-to-session continuity store (default beside the key file; an existing legacy `.buzz-sessions.json` is reused) |
|
|
92
124
|
| `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. |
|
|
93
125
|
| `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. |
|
|
126
|
+
| `BUZZ_CARDS` | `off` stops posting cards after replies and hidden questions in prompts (default on) |
|
|
94
127
|
| `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) |
|
|
95
128
|
| `AGENTID_MIRROR_SUFFIX` | Where names serve their public documents (default `.agentid.dev`); for staging only |
|
|
96
129
|
| `KYBERNESIS_ISSUER` | The control plane |
|
package/dist/bridge.d.ts
CHANGED
|
@@ -23,6 +23,14 @@ export type BuzzBridgeOptions = {
|
|
|
23
23
|
agentSilenceTimeoutMs?: number;
|
|
24
24
|
/** Ceiling on a turn once acknowledged; the agent is working while its stream is quiet. Default 60 minutes. */
|
|
25
25
|
agentWorkTimeoutMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Post the cards a turn's tools handed back, each as its own message after
|
|
28
|
+
* the reply: the card's text, with the card hidden in a comment for clients
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
cards?: boolean;
|
|
26
34
|
onLog?: (message: string) => void;
|
|
27
35
|
};
|
|
28
36
|
export declare function buzzBridge(options: BuzzBridgeOptions): {
|
package/dist/bridge.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { cardMessage, cardText, readHiddenAnswers } from "@kybernesis/cards";
|
|
1
2
|
import { channelIdentity } from "@kybernesis/enterprise";
|
|
2
3
|
import { fetchMedia, parseMedia } from "./media.js";
|
|
3
4
|
import { speakerCredentials } from "./credentials.js";
|
|
4
5
|
import { surfaceWriter } from "./surface.js";
|
|
5
6
|
import { SessionStore } from "./sessions.js";
|
|
6
7
|
import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, DEFAULT_AGENT_WORK_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
|
|
7
|
-
import { followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, resumeStalledReply, } from "./hitl.js";
|
|
8
|
+
import { followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, needsPendingFollower, resolveCarriedAnswers, resolveInputReply, respondToPendingConversation, resumeFailedReply, resumeStalledReply, STALE_ANSWER_REPLY, } from "./hitl.js";
|
|
8
9
|
import { existsSync } from "node:fs";
|
|
9
10
|
import { dirname, join } from "node:path";
|
|
10
11
|
import { Client } from "eve/client";
|
|
@@ -34,6 +35,66 @@ export function buzzBridge(options) {
|
|
|
34
35
|
const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
|
|
35
36
|
const agentWorkTimeoutMs = validateAgentSilenceTimeoutMs(options.agentWorkTimeoutMs ?? DEFAULT_AGENT_WORK_TIMEOUT_MS);
|
|
36
37
|
const agentSilenceTimeoutMs = validateAgentSilenceTimeoutMs(options.agentSilenceTimeoutMs ?? DEFAULT_AGENT_SILENCE_TIMEOUT_MS);
|
|
38
|
+
/**
|
|
39
|
+
* An artifact card, with a link everyone in the channel can open.
|
|
40
|
+
*
|
|
41
|
+
* Posting an artifact into a channel shares it with the channel: that is
|
|
42
|
+
* what the person asked for by asking here. The link is made by the agent's
|
|
43
|
+
* own share route, as the person who asked (the artifact's owner), so it is
|
|
44
|
+
* their act and they can withdraw it from their artifacts. If the link
|
|
45
|
+
* cannot be made, the card goes out as it was and says where to open it.
|
|
46
|
+
*/
|
|
47
|
+
const sharedInChannel = async (card, speaker) => {
|
|
48
|
+
if (card.card !== "artifact" || !card.data || typeof card.data !== "object")
|
|
49
|
+
return card;
|
|
50
|
+
const data = card.data;
|
|
51
|
+
if (typeof data.artifactId !== "string" || typeof data.url === "string")
|
|
52
|
+
return card;
|
|
53
|
+
try {
|
|
54
|
+
const credentials = credentialsFor(speaker);
|
|
55
|
+
const response = await fetch(`${options.agentUrl.replace(/\/$/, "")}/eve/v1/kyb/artifacts/${encodeURIComponent(data.artifactId)}/share`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: {
|
|
58
|
+
...(await credentials.headers()),
|
|
59
|
+
authorization: `Bearer ${await credentials.bearer()}`,
|
|
60
|
+
"content-type": "application/json",
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify(typeof data.version === "number" ? { version: data.version } : {}),
|
|
63
|
+
signal: AbortSignal.timeout(15_000),
|
|
64
|
+
});
|
|
65
|
+
const body = (await response.json().catch(() => null));
|
|
66
|
+
if (!response.ok || !body?.ok || typeof body.url !== "string") {
|
|
67
|
+
log(`could not share artifact ${data.artifactId.slice(0, 8)} (${response.status} ${String(body?.error ?? "")})`);
|
|
68
|
+
return card;
|
|
69
|
+
}
|
|
70
|
+
// The words people without the plugin read now carry the link.
|
|
71
|
+
const shared = { ...card, data: { ...data, url: body.url }, text: undefined };
|
|
72
|
+
return { ...shared, text: cardText(shared) };
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
log(`could not share artifact ${data.artifactId.slice(0, 8)} (${error.message})`);
|
|
76
|
+
return card;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* A turn's cards, one message each, threaded where the reply went.
|
|
81
|
+
*
|
|
82
|
+
* The reply stays an ordinary message the client draws itself, links and
|
|
83
|
+
* mentions included; a card-aware client swaps only these follow-ups for the
|
|
84
|
+
* card. They do not mention the asker again: the reply already did.
|
|
85
|
+
*/
|
|
86
|
+
const postCards = async (relay, channel, cards, speaker, replyTo) => {
|
|
87
|
+
if (options.cards === false || cards.length === 0)
|
|
88
|
+
return;
|
|
89
|
+
let posted = 0;
|
|
90
|
+
for (const card of await Promise.all(cards.map((c) => sharedInChannel(c, speaker)))) {
|
|
91
|
+
const content = cardMessage(card);
|
|
92
|
+
if (content && relay.reply(channel, content, replyTo, { mention: false }))
|
|
93
|
+
posted += 1;
|
|
94
|
+
}
|
|
95
|
+
if (posted)
|
|
96
|
+
log(`posted ${posted} card(s) in ${channel.slice(0, 8)}`);
|
|
97
|
+
};
|
|
37
98
|
/**
|
|
38
99
|
* One conversation per channel — the SESSION ID, not a handle to it.
|
|
39
100
|
*
|
|
@@ -236,7 +297,7 @@ export function buzzBridge(options) {
|
|
|
236
297
|
onInputRequested: (requests) => serialize(community, channel, async () => {
|
|
237
298
|
if (followers.get(conversation) !== controller)
|
|
238
299
|
return;
|
|
239
|
-
const promptId = relay.reply(channel, formatInputRequests(requests));
|
|
300
|
+
const promptId = relay.reply(channel, formatInputRequests(requests, { carry: options.cards !== false }));
|
|
240
301
|
if (!promptId)
|
|
241
302
|
throw new Error("Buzz relay is not connected");
|
|
242
303
|
relay.watchReplies([promptId]);
|
|
@@ -249,12 +310,24 @@ export function buzzBridge(options) {
|
|
|
249
310
|
}
|
|
250
311
|
log(`posted another input request in ${channel.slice(0, 8)}`);
|
|
251
312
|
}),
|
|
252
|
-
onMessage: (message) => serialize(community, channel, async () => {
|
|
313
|
+
onMessage: (message, cards) => serialize(community, channel, async () => {
|
|
253
314
|
if (followers.get(conversation) !== controller)
|
|
254
315
|
return;
|
|
255
|
-
if (!relay.reply(channel, message))
|
|
316
|
+
if (message.trim() && !relay.reply(channel, message))
|
|
256
317
|
throw new Error("Buzz relay is not connected");
|
|
257
|
-
|
|
318
|
+
if (message.trim())
|
|
319
|
+
log(`posted resumed reply in ${channel.slice(0, 8)} (${message.length} chars)`);
|
|
320
|
+
await postCards(relay, channel, cards, stored.speakerPublicKey);
|
|
321
|
+
}),
|
|
322
|
+
onFailed: (failures) => serialize(community, channel, async () => {
|
|
323
|
+
if (followers.get(conversation) !== controller)
|
|
324
|
+
return;
|
|
325
|
+
const current = sessions.get(community, channel);
|
|
326
|
+
if (current?.promptEventIds?.length)
|
|
327
|
+
relay.unwatchReplies(current.promptEventIds);
|
|
328
|
+
if (!relay.reply(channel, resumeFailedReply(failures)))
|
|
329
|
+
throw new Error("Buzz relay is not connected");
|
|
330
|
+
log(`resumed turn failed in ${channel.slice(0, 8)} (${describeSilentTurn(failures)}); told the room`);
|
|
258
331
|
}),
|
|
259
332
|
onLog: log,
|
|
260
333
|
}, controller.signal).catch((error) => {
|
|
@@ -315,9 +388,13 @@ export function buzzBridge(options) {
|
|
|
315
388
|
const addressed = event.tags.some((t) => t[0] === "p" && t[1] === key.publicKey);
|
|
316
389
|
// A reply to a prompt this agent posted is an answer even when it names nobody: people answer
|
|
317
390
|
// a question by replying to it, not by mentioning the asker. Only while that prompt is open.
|
|
318
|
-
|
|
391
|
+
// Any `e` tag counts: a reply inside a thread names the thread's root first and the prompt
|
|
392
|
+
// after it (NIP-10 `root` and `reply` markers), so the first tag is often not the prompt.
|
|
393
|
+
const referenced = event.tags.filter((t) => t[0] === "e" && typeof t[1] === "string").map((t) => t[1]);
|
|
394
|
+
// The message this one answers directly: the `reply` marker, else the last `e` tag.
|
|
395
|
+
const parent = event.tags.find((t) => t[0] === "e" && t[3] === "reply")?.[1] ?? referenced.at(-1);
|
|
319
396
|
const open = channel ? sessions.get(from, channel) : undefined;
|
|
320
|
-
const answersPrompt = Boolean(
|
|
397
|
+
const answersPrompt = Boolean(open?.pendingInputRequests?.length && referenced.some((id) => open.promptEventIds?.includes(id)));
|
|
321
398
|
// In a one-to-one DM every message is to the agent, mention or not, as with Buzz's own
|
|
322
399
|
// agents. Not one written by another agent (it carries its owner's `auth` attestation):
|
|
323
400
|
// two agents in a DM would otherwise answer each other forever.
|
|
@@ -351,9 +428,27 @@ export function buzzBridge(options) {
|
|
|
351
428
|
await serialize(from, channel, async () => {
|
|
352
429
|
const pending = sessions.get(from, channel);
|
|
353
430
|
if (pending?.pendingInputRequests?.length) {
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
431
|
+
let responses;
|
|
432
|
+
const carried = readHiddenAnswers(text);
|
|
433
|
+
if (carried.length) {
|
|
434
|
+
// A press in the plugin. It counts only as a direct reply to a prompt this bridge
|
|
435
|
+
// posted, so a message quoting someone else's question cannot answer this one.
|
|
436
|
+
if (!parent || !pending.promptEventIds?.includes(parent)) {
|
|
437
|
+
log(`ignored a pressed answer in ${channel.slice(0, 8)} that does not reply to an open prompt`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const resolved = resolveCarriedAnswers(carried, pending.pendingInputRequests);
|
|
441
|
+
if (!resolved.responses) {
|
|
442
|
+
relay.reply(channel, resolved.stale ? STALE_ANSWER_REPLY : invalidInputReply(pending.pendingInputRequests), event);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
responses = resolved.responses;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
// "@Ava approve" is an approve. The mention is how the message reached us, not the answer.
|
|
449
|
+
const answer = addressed ? text.replace(/^@\S+\s*/, "") : text;
|
|
450
|
+
responses = resolveInputReply(answer, pending.pendingInputRequests);
|
|
451
|
+
}
|
|
357
452
|
if (!responses) {
|
|
358
453
|
relay.reply(channel, invalidInputReply(pending.pendingInputRequests), event);
|
|
359
454
|
return;
|
|
@@ -369,7 +464,7 @@ export function buzzBridge(options) {
|
|
|
369
464
|
const session = clientFor(event.pubkey).sessions.attach(pending.id, {
|
|
370
465
|
streamIndex: pending.streamIndex,
|
|
371
466
|
});
|
|
372
|
-
await respondToPendingConversation(session, responses
|
|
467
|
+
await respondToPendingConversation(session, responses);
|
|
373
468
|
log(`submitted ${responses.length} input response(s) in ${channel.slice(0, 8)}`);
|
|
374
469
|
/**
|
|
375
470
|
* The answer is in, so this conversation is no longer waiting on a
|
|
@@ -384,10 +479,13 @@ export function buzzBridge(options) {
|
|
|
384
479
|
* a human, so while that flag was set the room could only be told
|
|
385
480
|
* nothing.
|
|
386
481
|
*/
|
|
482
|
+
// A press answers one question; any others stay open until they are answered too.
|
|
387
483
|
const answered = sessions.get(from, channel);
|
|
388
484
|
if (answered?.pendingInputRequests?.length) {
|
|
485
|
+
const done = new Set(responses.map((response) => response.requestId));
|
|
486
|
+
const remaining = answered.pendingInputRequests.filter((request) => !done.has(request.requestId));
|
|
389
487
|
const { pendingInputRequests: _resolved, ...rest } = answered;
|
|
390
|
-
sessions.set(from, channel, { ...rest, resumeInFlight: true });
|
|
488
|
+
sessions.set(from, channel, remaining.length ? { ...rest, pendingInputRequests: remaining } : { ...rest, resumeInFlight: true });
|
|
391
489
|
}
|
|
392
490
|
return;
|
|
393
491
|
}
|
|
@@ -411,7 +509,7 @@ export function buzzBridge(options) {
|
|
|
411
509
|
pendingInputRequests: result.inputRequests,
|
|
412
510
|
speakerPublicKey: event.pubkey,
|
|
413
511
|
});
|
|
414
|
-
const promptId = relay.reply(channel, formatInputRequests(result.inputRequests), event);
|
|
512
|
+
const promptId = relay.reply(channel, formatInputRequests(result.inputRequests, { carry: options.cards !== false }), event);
|
|
415
513
|
if (promptId) {
|
|
416
514
|
relay.watchReplies([promptId]);
|
|
417
515
|
const current = sessions.get(from, channel);
|
|
@@ -422,6 +520,10 @@ export function buzzBridge(options) {
|
|
|
422
520
|
startFollower(from, channel);
|
|
423
521
|
return;
|
|
424
522
|
}
|
|
523
|
+
if (!result.message && result.cards.length) {
|
|
524
|
+
await postCards(relay, channel, result.cards, event.pubkey, event);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
425
527
|
if (!result.message) {
|
|
426
528
|
// Say what the stream said: a failed tool by name and error, a
|
|
427
529
|
// failed model call by code, or, only when there is truly nothing,
|
|
@@ -432,6 +534,7 @@ export function buzzBridge(options) {
|
|
|
432
534
|
}
|
|
433
535
|
relay.reply(channel, result.message, event);
|
|
434
536
|
log(`replied (${result.message.length} chars)`);
|
|
537
|
+
await postCards(relay, channel, result.cards, event.pubkey, event);
|
|
435
538
|
});
|
|
436
539
|
}
|
|
437
540
|
catch (error) {
|
package/dist/cli.js
CHANGED
|
@@ -230,6 +230,7 @@ function run() {
|
|
|
230
230
|
...(process.env.BUZZ_AGENT_WORK_TIMEOUT_MS
|
|
231
231
|
? { agentWorkTimeoutMs: Number(process.env.BUZZ_AGENT_WORK_TIMEOUT_MS) }
|
|
232
232
|
: {}),
|
|
233
|
+
...(process.env.BUZZ_CARDS === "off" ? { cards: false } : {}),
|
|
233
234
|
...(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS
|
|
234
235
|
? { agentSilenceTimeoutMs: Number(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS) }
|
|
235
236
|
: {}),
|
|
@@ -422,6 +423,7 @@ else {
|
|
|
422
423
|
BUZZ_SESSIONS_FILE channel-to-session continuity store
|
|
423
424
|
BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum silence before a turn is acknowledged (default 300000ms)
|
|
424
425
|
BUZZ_AGENT_WORK_TIMEOUT_MS ceiling on a turn once acknowledged (default 3600000ms)
|
|
426
|
+
BUZZ_CARDS=off post replies without the cards tools handed back
|
|
425
427
|
BUZZ_PROFILE_SYNC_MS how often the profile is re-checked against the name and
|
|
426
428
|
picture its owner set (default 900000ms, fifteen
|
|
427
429
|
minutes; 0 turns it off)
|
package/dist/hitl.d.ts
CHANGED
|
@@ -1,18 +1,52 @@
|
|
|
1
1
|
import { type ClientSession, type InputRequest, type InputResponse } from "eve/client";
|
|
2
|
+
import { type AskAnswer, type Card } from "@kybernesis/cards";
|
|
2
3
|
import type { StoredSession } from "./sessions.js";
|
|
4
|
+
import { type TurnFailure } from "./turn.js";
|
|
3
5
|
/**
|
|
4
|
-
* Render pending Eve input in a
|
|
6
|
+
* Render pending Eve input in a Buzz channel.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
+
* Everyone reads the words: a question's sentence (a `[kyb:…]` marker line is
|
|
9
|
+
* dropped), an approval as the email or event it would send, then the options
|
|
10
|
+
* by number. With `carry`, each question also travels hidden after the words
|
|
11
|
+
* (`<!-- kyb:ask v1 … -->`), so the Kybernesis plugin can draw it the way
|
|
12
|
+
* Studio does and answer it with a press. Only a posted prompt carries them:
|
|
13
|
+
* a press is accepted only as a reply to a prompt the bridge is watching.
|
|
8
14
|
*/
|
|
9
|
-
export declare function formatInputRequests(requests: readonly InputRequest[]
|
|
15
|
+
export declare function formatInputRequests(requests: readonly InputRequest[], options?: {
|
|
16
|
+
carry?: boolean;
|
|
17
|
+
}): string;
|
|
18
|
+
/**
|
|
19
|
+
* Answers a client pressed, checked against what is actually open.
|
|
20
|
+
*
|
|
21
|
+
* Each answer must name an open request. An option must be one of that
|
|
22
|
+
* request's own; words alone answer only a question that takes words. eve's
|
|
23
|
+
* strict schema checks the result before it is sent. `stale` is true when no
|
|
24
|
+
* answer named an open request, which is what a second press on a question
|
|
25
|
+
* someone already answered looks like.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveCarriedAnswers(answers: readonly AskAnswer[], requests: readonly InputRequest[]): {
|
|
28
|
+
responses: readonly InputResponse[];
|
|
29
|
+
stale: false;
|
|
30
|
+
} | {
|
|
31
|
+
responses: null;
|
|
32
|
+
stale: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** What a press on a question nobody is waiting on any more gets. */
|
|
35
|
+
export declare const STALE_ANSWER_REPLY = "That question was already answered, so nothing changed.";
|
|
10
36
|
/** Resolve a Buzz text reply using Eve's certified matching semantics. */
|
|
11
37
|
export declare function resolveInputReply(text: string, requests: readonly InputRequest[]): readonly InputResponse[] | null;
|
|
12
38
|
/** Actionable response for text that cannot satisfy every pending request. */
|
|
13
39
|
export declare function invalidInputReply(requests: readonly InputRequest[]): string;
|
|
14
|
-
/**
|
|
15
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Submit a matched reply without consuming its response stream.
|
|
42
|
+
*
|
|
43
|
+
* No `clientContext`: the turn already carries the channel from when it
|
|
44
|
+
* started, and eve 0.81 treats context on a respond as new turn input. It then
|
|
45
|
+
* runs one more model step after the agent's reply, with the agent's message
|
|
46
|
+
* last, which models that refuse prefill reject. The turn fails and the whole
|
|
47
|
+
* conversation ends, on an approve, a decline or an answer alike.
|
|
48
|
+
*/
|
|
49
|
+
export declare function respondToPendingConversation(session: Pick<ClientSession, "respond">, responses: readonly InputResponse[]): Promise<void>;
|
|
16
50
|
/**
|
|
17
51
|
* How long a follower waits between reattaching when it saw no progress.
|
|
18
52
|
*
|
|
@@ -33,13 +67,26 @@ export declare function followerRetryDelayMs(cycles: number, capMs?: number): nu
|
|
|
33
67
|
* Say the answer arrived, say the work did not, and say the channel is usable.
|
|
34
68
|
*/
|
|
35
69
|
export declare function resumeStalledReply(stallMs: number): string;
|
|
70
|
+
/**
|
|
71
|
+
* What the room is told when the turn an answer resumed failed.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* Found live: a declined approval resumed the turn, the model refused the
|
|
75
|
+
* continuation, and the run ended. The follower saw the stream end and said
|
|
76
|
+
* nothing, so the person who pressed Decline got silence. They answered; the
|
|
77
|
+
* failure is the one thing they must hear about, in the stream's own words.
|
|
78
|
+
*/
|
|
79
|
+
export declare function resumeFailedReply(failures: readonly TurnFailure[]): string;
|
|
36
80
|
/** A session needs its follower while asking or while resumed output is unpublished. */
|
|
37
81
|
export declare function needsPendingFollower(session: Pick<StoredSession, "pendingInputRequests" | "resumeInFlight">): boolean;
|
|
38
82
|
export interface PendingFollowerCallbacks {
|
|
39
83
|
/** Persist the latest cursor and supervision state before publishing outward. */
|
|
40
84
|
onState(state: Omit<StoredSession, "updated">): void | Promise<void>;
|
|
41
85
|
onInputRequested(requests: readonly InputRequest[]): void | Promise<void>;
|
|
42
|
-
|
|
86
|
+
/** The resumed turn's words, and the cards its tools handed back. */
|
|
87
|
+
onMessage(message: string, cards: readonly Card[]): void | Promise<void>;
|
|
88
|
+
/** The resumed turn failed: what the stream said, for the room. */
|
|
89
|
+
onFailed?(failures: readonly TurnFailure[]): void | Promise<void>;
|
|
43
90
|
onLog?: (message: string) => void;
|
|
44
91
|
}
|
|
45
92
|
type MutableStoredSession = Omit<StoredSession, "updated">;
|
package/dist/hitl.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { resolveTextToResponses, } from "eve/client";
|
|
2
|
-
import {
|
|
1
|
+
import { parseInputResponses, resolveTextToResponses, } from "eve/client";
|
|
2
|
+
import { askFrom, askText, cardsOf, hiddenAsk, newStepState, readStep } 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.
|
|
8
13
|
*/
|
|
9
|
-
export function formatInputRequests(requests) {
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
export function formatInputRequests(requests, options = {}) {
|
|
15
|
+
const asks = requests.map((request) => askFrom(request));
|
|
16
|
+
const text = asks.map((ask, requestIndex) => {
|
|
17
|
+
const request = requests[requestIndex];
|
|
18
|
+
const prompt = askText(ask);
|
|
12
19
|
const lines = [
|
|
13
20
|
requests.length > 1 ? `Question ${requestIndex + 1}: ${prompt}` : prompt,
|
|
14
21
|
];
|
|
@@ -26,7 +33,58 @@ export function formatInputRequests(requests) {
|
|
|
26
33
|
}
|
|
27
34
|
return lines.join("\n");
|
|
28
35
|
}).join("\n\n");
|
|
36
|
+
if (!options.carry)
|
|
37
|
+
return text;
|
|
38
|
+
const hidden = asks.map((ask) => hiddenAsk(ask)).filter((h) => h !== null);
|
|
39
|
+
return hidden.length ? `${text}\n\n${hidden.join("\n")}` : text;
|
|
29
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Answers a client pressed, checked against what is actually open.
|
|
43
|
+
*
|
|
44
|
+
* Each answer must name an open request. An option must be one of that
|
|
45
|
+
* request's own; words alone answer only a question that takes words. eve's
|
|
46
|
+
* strict schema checks the result before it is sent. `stale` is true when no
|
|
47
|
+
* answer named an open request, which is what a second press on a question
|
|
48
|
+
* someone already answered looks like.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveCarriedAnswers(answers, requests) {
|
|
51
|
+
const open = new Map(requests.map((request) => [request.requestId, request]));
|
|
52
|
+
const picked = new Map();
|
|
53
|
+
let named = false;
|
|
54
|
+
for (const answer of answers) {
|
|
55
|
+
const request = open.get(answer.requestId);
|
|
56
|
+
if (!request)
|
|
57
|
+
continue;
|
|
58
|
+
named = true;
|
|
59
|
+
if (picked.has(request.requestId))
|
|
60
|
+
continue;
|
|
61
|
+
const ids = request.options?.map((option) => option.id) ?? [];
|
|
62
|
+
if (answer.optionId !== undefined) {
|
|
63
|
+
if (ids.length > 0 && !ids.includes(answer.optionId))
|
|
64
|
+
return { responses: null, stale: false };
|
|
65
|
+
picked.set(request.requestId, {
|
|
66
|
+
requestId: request.requestId,
|
|
67
|
+
optionId: answer.optionId,
|
|
68
|
+
...(answer.text?.trim() ? { text: answer.text.trim() } : {}),
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const words = answer.text?.trim();
|
|
73
|
+
if (!words || (ids.length > 0 && request.allowFreeform !== true))
|
|
74
|
+
return { responses: null, stale: false };
|
|
75
|
+
picked.set(request.requestId, { requestId: request.requestId, text: words });
|
|
76
|
+
}
|
|
77
|
+
if (picked.size === 0)
|
|
78
|
+
return { responses: null, stale: !named };
|
|
79
|
+
try {
|
|
80
|
+
return { responses: parseInputResponses([...picked.values()]), stale: false };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { responses: null, stale: false };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** What a press on a question nobody is waiting on any more gets. */
|
|
87
|
+
export const STALE_ANSWER_REPLY = "That question was already answered, so nothing changed.";
|
|
30
88
|
/** Resolve a Buzz text reply using Eve's certified matching semantics. */
|
|
31
89
|
export function resolveInputReply(text, requests) {
|
|
32
90
|
if (!text.trim())
|
|
@@ -38,10 +96,17 @@ export function resolveInputReply(text, requests) {
|
|
|
38
96
|
export function invalidInputReply(requests) {
|
|
39
97
|
return `I still need a valid answer before I can continue.\n\n${formatInputRequests(requests)}`;
|
|
40
98
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Submit a matched reply without consuming its response stream.
|
|
101
|
+
*
|
|
102
|
+
* No `clientContext`: the turn already carries the channel from when it
|
|
103
|
+
* started, and eve 0.81 treats context on a respond as new turn input. It then
|
|
104
|
+
* runs one more model step after the agent's reply, with the agent's message
|
|
105
|
+
* last, which models that refuse prefill reject. The turn fails and the whole
|
|
106
|
+
* conversation ends, on an approve, a decline or an answer alike.
|
|
107
|
+
*/
|
|
108
|
+
export async function respondToPendingConversation(session, responses) {
|
|
43
109
|
await session.respond(responses, {
|
|
44
|
-
clientContext: { buzzCommunity: community, buzzChannel: channel },
|
|
45
110
|
streamReconnectPolicy: { reconnect: false },
|
|
46
111
|
});
|
|
47
112
|
}
|
|
@@ -73,6 +138,19 @@ export function resumeStalledReply(stallMs) {
|
|
|
73
138
|
`${minutes === 1 ? "" : "s"} without producing anything, so I have stopped waiting on it. ` +
|
|
74
139
|
`Nothing was posted and no work landed from it. This channel is clear — ask me again and I will start fresh.`);
|
|
75
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* What the room is told when the turn an answer resumed failed.
|
|
143
|
+
*
|
|
144
|
+
* @remarks
|
|
145
|
+
* Found live: a declined approval resumed the turn, the model refused the
|
|
146
|
+
* continuation, and the run ended. The follower saw the stream end and said
|
|
147
|
+
* nothing, so the person who pressed Decline got silence. They answered; the
|
|
148
|
+
* failure is the one thing they must hear about, in the stream's own words.
|
|
149
|
+
*/
|
|
150
|
+
export function resumeFailedReply(failures) {
|
|
151
|
+
const said = silentTurnReply(failures);
|
|
152
|
+
return `Your answer reached me, but the work it restarted stopped. ${said}`;
|
|
153
|
+
}
|
|
76
154
|
/** A session needs its follower while asking or while resumed output is unpublished. */
|
|
77
155
|
export function needsPendingFollower(session) {
|
|
78
156
|
return Boolean(session.pendingInputRequests?.length || session.resumeInFlight);
|
|
@@ -105,6 +183,17 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
105
183
|
let state = { ...initial };
|
|
106
184
|
let streamIndex = initial.streamIndex;
|
|
107
185
|
let completedMessage = "";
|
|
186
|
+
let steps = newStepState();
|
|
187
|
+
/** Failures since the last answer. A declined call is the person's choice, not one. */
|
|
188
|
+
let failures = [];
|
|
189
|
+
/** Tell the room, then stop supervising: a failed run will not come back. */
|
|
190
|
+
const failThenClear = async () => {
|
|
191
|
+
// Only an answered question resumed this work; a run failing while it waits on a person stays quiet as before.
|
|
192
|
+
if (state.resumeInFlight)
|
|
193
|
+
await callbacks.onFailed?.(failures);
|
|
194
|
+
state = withoutSupervision(state);
|
|
195
|
+
await persist();
|
|
196
|
+
};
|
|
108
197
|
const persist = async () => {
|
|
109
198
|
state = { ...state, streamIndex };
|
|
110
199
|
await callbacks.onState(state);
|
|
@@ -113,8 +202,9 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
113
202
|
// Do not persist past message.completed/turn.completed until publication
|
|
114
203
|
// succeeds. If this callback throws or the process dies, recovery replays
|
|
115
204
|
// the unpublished output from the last safe cursor.
|
|
116
|
-
|
|
117
|
-
|
|
205
|
+
const cards = cardsOf(steps.steps.values());
|
|
206
|
+
if (completedMessage.trim() || cards.length)
|
|
207
|
+
await callbacks.onMessage(completedMessage, cards);
|
|
118
208
|
state = withoutSupervision(state);
|
|
119
209
|
await persist();
|
|
120
210
|
};
|
|
@@ -130,9 +220,16 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
130
220
|
// closes; count yielded durable events so crash recovery can persist while
|
|
131
221
|
// the follower is still open.
|
|
132
222
|
streamIndex += 1;
|
|
223
|
+
readStep(steps, streamEvent.type, streamEvent.data);
|
|
224
|
+
const declined = streamEvent.type === "action.result" && streamEvent.data.status === "rejected";
|
|
225
|
+
const failure = declined ? null : failureFromEvent(streamEvent);
|
|
226
|
+
if (failure)
|
|
227
|
+
failures.push(failure);
|
|
133
228
|
switch (streamEvent.type) {
|
|
134
229
|
case "input.requested":
|
|
135
230
|
completedMessage = "";
|
|
231
|
+
steps = newStepState();
|
|
232
|
+
failures = [];
|
|
136
233
|
state = withPending(state, streamEvent.data.requests);
|
|
137
234
|
// Keep the durable cursor before the prompt until Buzz accepts it for
|
|
138
235
|
// relay delivery; a reconnect can then replay and render the request.
|
|
@@ -165,9 +262,11 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
165
262
|
}
|
|
166
263
|
await persist();
|
|
167
264
|
break;
|
|
168
|
-
case "turn.cancelled":
|
|
169
265
|
case "turn.failed":
|
|
170
266
|
case "session.failed":
|
|
267
|
+
await failThenClear();
|
|
268
|
+
return;
|
|
269
|
+
case "turn.cancelled":
|
|
171
270
|
case "session.completed":
|
|
172
271
|
state = withoutSupervision(state);
|
|
173
272
|
await persist();
|
|
@@ -179,5 +278,16 @@ export async function followPendingConversation(session, initial, callbacks, sig
|
|
|
179
278
|
break;
|
|
180
279
|
}
|
|
181
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* The stream closed without a boundary. Usually a dropped connection, and
|
|
283
|
+
* the caller reattaches. But after an answer, a model failure followed by
|
|
284
|
+
* the end of the stream is the run ending: reattaching finds nothing new,
|
|
285
|
+
* and the room would be told only after the work ceiling, an hour later.
|
|
286
|
+
*/
|
|
287
|
+
if (state.resumeInFlight && failures.some((f) => f.kind === "model")) {
|
|
288
|
+
callbacks.onLog?.("resumed turn failed and its stream ended; telling the room");
|
|
289
|
+
await failThenClear();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
182
292
|
callbacks.onLog?.("pending Eve stream ended before a resumable boundary");
|
|
183
293
|
}
|
package/dist/relay.d.ts
CHANGED
|
@@ -37,6 +37,9 @@ export declare const TYPING_INTERVAL_MS = 3000;
|
|
|
37
37
|
export declare const PRESENCE_INTERVAL_MS = 60000;
|
|
38
38
|
/** Enough overlap for same-second redelivery without retaining every event forever. */
|
|
39
39
|
export declare const MAX_SEEN_EVENT_IDS = 10000;
|
|
40
|
+
/** At most this many messages wait for a reconnect, and none older than this goes out late. */
|
|
41
|
+
export declare const MAX_OUTBOX = 100;
|
|
42
|
+
export declare const OUTBOX_MAX_AGE_MS: number;
|
|
40
43
|
/** How often to re-read which direct conversations this agent is in. */
|
|
41
44
|
export declare const DIRECT_REFRESH_MS = 30000;
|
|
42
45
|
export type NostrEvent = {
|
|
@@ -69,6 +72,14 @@ export declare class BuzzRelay {
|
|
|
69
72
|
private refusal;
|
|
70
73
|
/** Answered already, so a redelivery in two overlapping polls is not answered twice. */
|
|
71
74
|
private readonly seen;
|
|
75
|
+
/**
|
|
76
|
+
* Messages and reactions signed while the connection was down or not yet signed in.
|
|
77
|
+
*
|
|
78
|
+
* The relay drops in and out every few minutes. A question posted in one of those gaps
|
|
79
|
+
* used to vanish: the person never saw it, and their next message was taken as a wrong
|
|
80
|
+
* answer to it. They go out, in order, as soon as the connection is signed in again.
|
|
81
|
+
*/
|
|
82
|
+
private readonly outbox;
|
|
72
83
|
private cursor;
|
|
73
84
|
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
74
85
|
private readonly replyWatch;
|
|
@@ -134,17 +145,24 @@ export declare class BuzzRelay {
|
|
|
134
145
|
private discoveryDone;
|
|
135
146
|
/** Take the DMs just found; read back any that are new, from when they could last have been missed. */
|
|
136
147
|
private adopt;
|
|
137
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Publish one event; returns its id when it left this process or waits in the outbox, null
|
|
150
|
+
* when it was dropped. Presence and typing are dropped while offline: late, they are wrong.
|
|
151
|
+
*/
|
|
138
152
|
publish(event: {
|
|
139
153
|
kind: number;
|
|
140
154
|
tags: string[][];
|
|
141
155
|
content: string;
|
|
142
156
|
}): string | null;
|
|
157
|
+
private flushOutbox;
|
|
143
158
|
setPresence(status: PresenceStatus): void;
|
|
144
159
|
private keepPresent;
|
|
145
160
|
/** Say "typing" in a channel until the returned function is called. */
|
|
146
161
|
typingIn(channel: string): () => void;
|
|
147
|
-
|
|
162
|
+
/** `mention: false` threads under `replyTo` without notifying its author again, as a follow-up to a reply does. */
|
|
163
|
+
reply(channel: string, text: string, replyTo?: NostrEvent, options?: {
|
|
164
|
+
mention?: boolean;
|
|
165
|
+
}): string | null;
|
|
148
166
|
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
149
167
|
private httpAuth;
|
|
150
168
|
private submit;
|
package/dist/relay.js
CHANGED
|
@@ -38,6 +38,9 @@ export const TYPING_INTERVAL_MS = 3_000;
|
|
|
38
38
|
export const PRESENCE_INTERVAL_MS = 60_000;
|
|
39
39
|
/** Enough overlap for same-second redelivery without retaining every event forever. */
|
|
40
40
|
export const MAX_SEEN_EVENT_IDS = 10_000;
|
|
41
|
+
/** At most this many messages wait for a reconnect, and none older than this goes out late. */
|
|
42
|
+
export const MAX_OUTBOX = 100;
|
|
43
|
+
export const OUTBOX_MAX_AGE_MS = 15 * 60_000;
|
|
41
44
|
/** How often to re-read which direct conversations this agent is in. */
|
|
42
45
|
export const DIRECT_REFRESH_MS = 30_000;
|
|
43
46
|
/** Ids per `#d`/`#h` filter, and filters per request: the relay caps a request at ten. */
|
|
@@ -68,6 +71,14 @@ export class BuzzRelay {
|
|
|
68
71
|
refusal = null;
|
|
69
72
|
/** Answered already, so a redelivery in two overlapping polls is not answered twice. */
|
|
70
73
|
seen = new Set();
|
|
74
|
+
/**
|
|
75
|
+
* Messages and reactions signed while the connection was down or not yet signed in.
|
|
76
|
+
*
|
|
77
|
+
* The relay drops in and out every few minutes. A question posted in one of those gaps
|
|
78
|
+
* used to vanish: the person never saw it, and their next message was taken as a wrong
|
|
79
|
+
* answer to it. They go out, in order, as soon as the connection is signed in again.
|
|
80
|
+
*/
|
|
81
|
+
outbox = [];
|
|
71
82
|
cursor = now();
|
|
72
83
|
/** Prompts this agent posted; replies to them are polled for even when they carry no mention. */
|
|
73
84
|
replyWatch = new Set();
|
|
@@ -229,6 +240,7 @@ export class BuzzRelay {
|
|
|
229
240
|
this.backoff = 5_000;
|
|
230
241
|
this.refusal = null;
|
|
231
242
|
this.log(`authenticated as ${this.key.publicKey.slice(0, 12)}…`);
|
|
243
|
+
this.flushOutbox();
|
|
232
244
|
this.keepPresent();
|
|
233
245
|
this.discoverDirect();
|
|
234
246
|
this.poll();
|
|
@@ -404,10 +416,38 @@ export class BuzzRelay {
|
|
|
404
416
|
this.send(["REQ", `m${Date.now()}c`, ...filters]);
|
|
405
417
|
}
|
|
406
418
|
}
|
|
407
|
-
/**
|
|
419
|
+
/**
|
|
420
|
+
* Publish one event; returns its id when it left this process or waits in the outbox, null
|
|
421
|
+
* when it was dropped. Presence and typing are dropped while offline: late, they are wrong.
|
|
422
|
+
*/
|
|
408
423
|
publish(event) {
|
|
409
424
|
const signed = finalizeEvent({ created_at: now(), ...event }, this.key.secretKey);
|
|
410
|
-
|
|
425
|
+
if (this.authenticated && this.send(["EVENT", signed]))
|
|
426
|
+
return signed.id;
|
|
427
|
+
// Held only between connections of a running relay; one never started or closed sends nothing.
|
|
428
|
+
if (this.closed || !this.socket || (event.kind !== KIND_MESSAGE && event.kind !== KIND_REACTION))
|
|
429
|
+
return null;
|
|
430
|
+
if (this.outbox.length >= MAX_OUTBOX)
|
|
431
|
+
this.outbox.shift();
|
|
432
|
+
this.outbox.push({ event: signed, at: Date.now() });
|
|
433
|
+
return signed.id;
|
|
434
|
+
}
|
|
435
|
+
flushOutbox() {
|
|
436
|
+
const cutoff = Date.now() - OUTBOX_MAX_AGE_MS;
|
|
437
|
+
const stale = this.outbox.filter(({ at }) => at < cutoff).length;
|
|
438
|
+
if (stale)
|
|
439
|
+
this.log(`dropped ${stale} message(s) held too long while disconnected`);
|
|
440
|
+
const due = this.outbox.splice(0).filter(({ at }) => at >= cutoff);
|
|
441
|
+
let sent = 0;
|
|
442
|
+
for (const held of due) {
|
|
443
|
+
if (!this.send(["EVENT", held.event]))
|
|
444
|
+
break;
|
|
445
|
+
sent += 1;
|
|
446
|
+
}
|
|
447
|
+
// The connection dropped again mid-flush: the rest wait for the next sign-in, in order.
|
|
448
|
+
this.outbox.unshift(...due.slice(sent));
|
|
449
|
+
if (sent)
|
|
450
|
+
this.log(`sent ${sent} message(s) held while disconnected`);
|
|
411
451
|
}
|
|
412
452
|
setPresence(status) {
|
|
413
453
|
this.publish({ kind: KIND_PRESENCE, tags: [], content: status });
|
|
@@ -425,10 +465,13 @@ export class BuzzRelay {
|
|
|
425
465
|
const timer = setInterval(tick, TYPING_INTERVAL_MS);
|
|
426
466
|
return () => clearInterval(timer);
|
|
427
467
|
}
|
|
428
|
-
|
|
468
|
+
/** `mention: false` threads under `replyTo` without notifying its author again, as a follow-up to a reply does. */
|
|
469
|
+
reply(channel, text, replyTo, options = {}) {
|
|
429
470
|
const tags = [["h", channel]];
|
|
430
471
|
if (replyTo) {
|
|
431
|
-
tags.push(["e", replyTo.id]
|
|
472
|
+
tags.push(["e", replyTo.id]);
|
|
473
|
+
if (options.mention !== false)
|
|
474
|
+
tags.push(["p", replyTo.pubkey]);
|
|
432
475
|
}
|
|
433
476
|
return this.publish({ kind: KIND_MESSAGE, tags, content: text });
|
|
434
477
|
}
|
package/dist/turn.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Client, type InputRequest, type MessageResult, type SendTurnInput } from "eve/client";
|
|
2
|
+
import { type Card } from "@kybernesis/cards";
|
|
2
3
|
import { type FetchedMedia, type MediaRef } from "./media.js";
|
|
3
4
|
import { SessionStore } from "./sessions.js";
|
|
4
5
|
/** Five minutes without an eve event or acknowledgement is treated as a stalled turn. */
|
|
@@ -64,6 +65,8 @@ export interface TurnOutcome {
|
|
|
64
65
|
streamIndex: number;
|
|
65
66
|
/** Failures seen on the stream, in order. Empty on a clean turn. */
|
|
66
67
|
failures: readonly TurnFailure[];
|
|
68
|
+
/** Cards the turn's tools handed back, read the way the apps read them. */
|
|
69
|
+
cards: readonly Card[];
|
|
67
70
|
}
|
|
68
71
|
/** Read a failure out of one stream event, or nothing when the event is not one. */
|
|
69
72
|
export declare function failureFromEvent(event: {
|
|
@@ -95,9 +98,10 @@ export declare const DEFAULT_UNREAD_DRAIN_TIMEOUT_MS = 20000;
|
|
|
95
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>;
|
|
96
99
|
/** What the room is told when eve refuses the turn itself. */
|
|
97
100
|
/**
|
|
98
|
-
* The
|
|
99
|
-
* turn: an unknown session id (404),
|
|
100
|
-
* `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
|
|
101
105
|
* when one of its turns fails hard, for instance a model call that 404s at
|
|
102
106
|
* the gateway; the channel must then start a fresh one.
|
|
103
107
|
*
|
package/dist/turn.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ClientError, createDataUrlFilePart } from "eve/client";
|
|
2
|
+
import { cardsOf, newStepState, readStep } from "@kybernesis/cards";
|
|
2
3
|
import { isImage } from "./media.js";
|
|
3
4
|
/** Five minutes without an eve event or acknowledgement is treated as a stalled turn. */
|
|
4
5
|
export const DEFAULT_AGENT_SILENCE_TIMEOUT_MS = 5 * 60_000;
|
|
@@ -210,6 +211,7 @@ async function collectResponse(response, watchdog, phase, streamIndex, continueS
|
|
|
210
211
|
let status = "completed";
|
|
211
212
|
let inputRequests = [];
|
|
212
213
|
let failures = [];
|
|
214
|
+
let steps = newStepState();
|
|
213
215
|
let sawTurnStart = false;
|
|
214
216
|
let endedAtOwnBoundary = false;
|
|
215
217
|
let staleBoundaryFirst = false;
|
|
@@ -240,8 +242,10 @@ async function collectResponse(response, watchdog, phase, streamIndex, continueS
|
|
|
240
242
|
status = "completed";
|
|
241
243
|
inputRequests = [];
|
|
242
244
|
failures = [];
|
|
245
|
+
steps = newStepState();
|
|
243
246
|
return false;
|
|
244
247
|
}
|
|
248
|
+
readStep(steps, event.type, event.data);
|
|
245
249
|
const failure = failureFromEvent(event);
|
|
246
250
|
if (failure)
|
|
247
251
|
failures.push(failure);
|
|
@@ -298,6 +302,7 @@ async function collectResponse(response, watchdog, phase, streamIndex, continueS
|
|
|
298
302
|
sessionId: response.sessionId,
|
|
299
303
|
streamIndex: streamIndex(),
|
|
300
304
|
failures,
|
|
305
|
+
cards: cardsOf(steps.steps.values()),
|
|
301
306
|
};
|
|
302
307
|
}
|
|
303
308
|
finally {
|
|
@@ -425,9 +430,10 @@ export async function answerTurn(client, sessions, channel, message, community,
|
|
|
425
430
|
}
|
|
426
431
|
/** What the room is told when eve refuses the turn itself. */
|
|
427
432
|
/**
|
|
428
|
-
* The
|
|
429
|
-
* turn: an unknown session id (404),
|
|
430
|
-
* `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
|
|
431
437
|
* when one of its turns fails hard, for instance a model call that 404s at
|
|
432
438
|
* the gateway; the channel must then start a fresh one.
|
|
433
439
|
*
|
|
@@ -441,7 +447,10 @@ export async function answerTurn(client, sessions, channel, message, community,
|
|
|
441
447
|
export function isSessionGone(error) {
|
|
442
448
|
if (!(error instanceof ClientError))
|
|
443
449
|
return false;
|
|
444
|
-
|
|
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)
|
|
445
454
|
return true;
|
|
446
455
|
return (error.status === 409 &&
|
|
447
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.14.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.5.0",
|
|
48
48
|
"@kybernesis/enterprise": "^0.10.0",
|
|
49
49
|
"nostr-tools": "^2.10.0",
|
|
50
50
|
"zod": "^4"
|