@kybernesis/buzz 0.14.0 → 0.16.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 +19 -1
- package/dist/bridge.d.ts +9 -0
- package/dist/bridge.js +105 -16
- package/dist/cli.js +2 -0
- package/dist/hitl.d.ts +39 -4
- package/dist/hitl.js +87 -8
- package/dist/pictures.d.ts +26 -0
- package/dist/pictures.js +90 -0
- package/dist/profile.d.ts +8 -0
- package/dist/profile.js +8 -4
- package/dist/relay.d.ts +12 -1
- package/dist/relay.js +16 -1
- package/dist/sessions.d.ts +20 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -95,12 +95,19 @@ 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
|
+
A screenshot the agent shows (`show_screen` in `@kybernesis/computer`) goes out as an ordinary Buzz
|
|
99
|
+
picture, not a card: the bridge fetches it from the agent as the person who asked
|
|
100
|
+
(`GET /eve/v1/kyb/screens/:id`, served only to them) and stores it in the community's own media
|
|
101
|
+
store, which serves members only. Every Buzz app draws it, with or without the plugin. Like an
|
|
102
|
+
artifact, a screenshot asked for in a channel is shared with that channel. If it cannot be
|
|
103
|
+
fetched or stored, the card goes out as before.
|
|
104
|
+
|
|
98
105
|
## Questions and approvals
|
|
99
106
|
|
|
100
107
|
When the agent needs a yes or an answer, the bridge posts the question as a reply to the message
|
|
101
108
|
that asked: an approval as the email, event or message it would send (recipients, subject and
|
|
102
109
|
the whole body, not "Approve tool call"), any other question as its sentence, then the options by
|
|
103
|
-
number.
|
|
110
|
+
number. The person who asked answers by replying with a number, an option or their own words. Since 0.14 the
|
|
104
111
|
question also travels hidden (`<!-- kyb:ask v1 {...} -->`), and the plugin draws it as Studio
|
|
105
112
|
does, with Send it, Edit and Don't send on an email, the amount and Approve in Link on a
|
|
106
113
|
purchase, and the fields of a form. A press posts the person's answer as a reply to the
|
|
@@ -109,6 +116,16 @@ bridge accepts a hidden answer only as a direct reply to a prompt it posted, onl
|
|
|
109
116
|
still open, and only with that request's own options. Replies inside a thread count, whichever
|
|
110
117
|
`e` tag names the prompt. A vault request never takes a secret in the channel.
|
|
111
118
|
|
|
119
|
+
Since 0.15 a question is for the person whose message raised it. Anyone else who presses an
|
|
120
|
+
answer, or names the agent while it waits, is told whose answer it needs; a remark in the
|
|
121
|
+
question's thread is left alone. The question carries who asked, so the plugin shows everyone
|
|
122
|
+
else the email with "Waiting on" and no buttons. `BUZZ_ANSWERS=anyone` keeps the older rule.
|
|
123
|
+
When a question ends (answered, declined, sent back with changes, or no longer waited on because
|
|
124
|
+
its work stopped) the bridge edits its own prompt to say how and by whom, with the question
|
|
125
|
+
hidden as `<!-- kyb:settled v1 {...} -->` instead. Only a message's author can edit it, so every
|
|
126
|
+
client, with the plugin or without, shows the same outcome and nobody is offered the buttons
|
|
127
|
+
again. The edit names nobody, so it notifies nobody.
|
|
128
|
+
|
|
112
129
|
If the work an answer restarts fails (a model refusing the continuation, say), the bridge says so
|
|
113
130
|
in the channel with the stream's own error, rather than leaving the person who answered with
|
|
114
131
|
silence. A declined call is the person's choice and is not reported as a failure.
|
|
@@ -124,6 +141,7 @@ silence. A declined call is the person's choice and is not reported as a failure
|
|
|
124
141
|
| `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. |
|
|
125
142
|
| `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
143
|
| `BUZZ_CARDS` | `off` stops posting cards after replies and hidden questions in prompts (default on) |
|
|
144
|
+
| `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 |
|
|
127
145
|
| `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) |
|
|
128
146
|
| `AGENTID_MIRROR_SUFFIX` | Where names serve their public documents (default `.agentid.dev`); for staging only |
|
|
129
147
|
| `KYBERNESIS_ISSUER` | The control plane |
|
package/dist/bridge.d.ts
CHANGED
|
@@ -31,6 +31,15 @@ export type BuzzBridgeOptions = {
|
|
|
31
31
|
* Default true; false posts the reply and questions as words alone.
|
|
32
32
|
*/
|
|
33
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";
|
|
34
43
|
onLog?: (message: string) => void;
|
|
35
44
|
};
|
|
36
45
|
export declare function buzzBridge(options: BuzzBridgeOptions): {
|
package/dist/bridge.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { cardMessage, cardText, readHiddenAnswers } from "@kybernesis/cards";
|
|
2
2
|
import { channelIdentity } from "@kybernesis/enterprise";
|
|
3
3
|
import { fetchMedia, parseMedia } from "./media.js";
|
|
4
|
+
import { isAgentScreenshot, pictureMessage } from "./pictures.js";
|
|
4
5
|
import { speakerCredentials } from "./credentials.js";
|
|
5
6
|
import { surfaceWriter } from "./surface.js";
|
|
6
7
|
import { SessionStore } from "./sessions.js";
|
|
7
8
|
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, resolveCarriedAnswers, resolveInputReply, respondToPendingConversation, resumeFailedReply, resumeStalledReply, STALE_ANSWER_REPLY, } from "./hitl.js";
|
|
9
|
+
import { applySettlements, followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, isAsker, needsPendingFollower, notTheAskerReply, settlementsFor, settleRemaining, resolveCarriedAnswers, resolveInputReply, respondToPendingConversation, resumeFailedReply, resumeStalledReply, STALE_ANSWER_REPLY, } from "./hitl.js";
|
|
9
10
|
import { existsSync } from "node:fs";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
12
|
import { Client } from "eve/client";
|
|
@@ -35,6 +36,8 @@ export function buzzBridge(options) {
|
|
|
35
36
|
const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
|
|
36
37
|
const agentWorkTimeoutMs = validateAgentSilenceTimeoutMs(options.agentWorkTimeoutMs ?? DEFAULT_AGENT_WORK_TIMEOUT_MS);
|
|
37
38
|
const agentSilenceTimeoutMs = validateAgentSilenceTimeoutMs(options.agentSilenceTimeoutMs ?? DEFAULT_AGENT_SILENCE_TIMEOUT_MS);
|
|
39
|
+
const askerOnly = options.answers !== "anyone";
|
|
40
|
+
const carry = options.cards !== false;
|
|
38
41
|
/**
|
|
39
42
|
* An artifact card, with a link everyone in the channel can open.
|
|
40
43
|
*
|
|
@@ -76,6 +79,27 @@ export function buzzBridge(options) {
|
|
|
76
79
|
return card;
|
|
77
80
|
}
|
|
78
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* A screenshot card as a picture everyone in the channel sees: fetched from
|
|
84
|
+
* the agent as the person who asked (it is theirs), stored in this
|
|
85
|
+
* community's media store. See pictures.ts. If that fails, the card goes out
|
|
86
|
+
* as it was.
|
|
87
|
+
*/
|
|
88
|
+
const screenshotIn = async (relay, card, speaker) => {
|
|
89
|
+
try {
|
|
90
|
+
const credentials = credentialsFor(speaker);
|
|
91
|
+
return await pictureMessage(card, {
|
|
92
|
+
agentUrl: options.agentUrl,
|
|
93
|
+
headers: async () => ({ ...(await credentials.headers()), authorization: `Bearer ${await credentials.bearer()}` }),
|
|
94
|
+
relay: relay.url,
|
|
95
|
+
key,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
log(`could not post a screenshot as a picture (${error.message})`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
79
103
|
/**
|
|
80
104
|
* A turn's cards, one message each, threaded where the reply went.
|
|
81
105
|
*
|
|
@@ -88,6 +112,12 @@ export function buzzBridge(options) {
|
|
|
88
112
|
return;
|
|
89
113
|
let posted = 0;
|
|
90
114
|
for (const card of await Promise.all(cards.map((c) => sharedInChannel(c, speaker)))) {
|
|
115
|
+
const picture = isAgentScreenshot(card) ? await screenshotIn(relay, card, speaker) : null;
|
|
116
|
+
if (picture) {
|
|
117
|
+
if (relay.reply(channel, picture.content, replyTo, { mention: false, tags: picture.tags }))
|
|
118
|
+
posted += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
91
121
|
const content = cardMessage(card);
|
|
92
122
|
if (content && relay.reply(channel, content, replyTo, { mention: false }))
|
|
93
123
|
posted += 1;
|
|
@@ -262,6 +292,49 @@ export function buzzBridge(options) {
|
|
|
262
292
|
followers.get(conversation)?.abort();
|
|
263
293
|
followers.delete(conversation);
|
|
264
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Edit each posted question whose requests just ended, so everyone who can
|
|
297
|
+
* see it reads how it ended and nobody is offered its buttons again.
|
|
298
|
+
*
|
|
299
|
+
* @remarks
|
|
300
|
+
* The plugin used to keep a pressed question's outcome only on the device
|
|
301
|
+
* that pressed it. In a shared channel everyone else, and the same person on
|
|
302
|
+
* another device, still saw Send it on an email that had gone. The prompt is
|
|
303
|
+
* the agent's own message and only its author may edit it, so the edit is
|
|
304
|
+
* the one record every client draws. Runs inside the conversation's queue.
|
|
305
|
+
*/
|
|
306
|
+
function settlePrompts(relay, community, channel, settlements) {
|
|
307
|
+
const current = sessions.get(community, channel);
|
|
308
|
+
if (!current?.prompts?.length || settlements.size === 0)
|
|
309
|
+
return;
|
|
310
|
+
const { edits, open } = applySettlements(current.prompts, settlements, { carry, askerOnly });
|
|
311
|
+
for (const { eventId, content } of edits) {
|
|
312
|
+
if (relay.edit(channel, eventId, content))
|
|
313
|
+
log(`marked a question in ${channel.slice(0, 8)} as ${[...new Set(settlements.values())].map((s) => s.outcome).join(", ")}`);
|
|
314
|
+
else
|
|
315
|
+
log(`could not mark a question in ${channel.slice(0, 8)} as ended: not connected`);
|
|
316
|
+
}
|
|
317
|
+
const { prompts: _prompts, ...rest } = current;
|
|
318
|
+
sessions.set(community, channel, open.length ? { ...rest, prompts: open } : rest);
|
|
319
|
+
}
|
|
320
|
+
/** Every question still open in this conversation ends the same way: answered elsewhere, or no longer waited on. */
|
|
321
|
+
function settleOpenPrompts(relay, community, channel, outcome) {
|
|
322
|
+
const current = sessions.get(community, channel);
|
|
323
|
+
if (!current?.prompts?.length)
|
|
324
|
+
return;
|
|
325
|
+
settlePrompts(relay, community, channel, settleRemaining(current.prompts, { outcome, at: Date.now() }));
|
|
326
|
+
}
|
|
327
|
+
/** Remember a posted question, so it can be edited when it ends. */
|
|
328
|
+
function rememberPrompt(community, channel, prompt) {
|
|
329
|
+
const current = sessions.get(community, channel);
|
|
330
|
+
if (!current)
|
|
331
|
+
return;
|
|
332
|
+
sessions.set(community, channel, {
|
|
333
|
+
...current,
|
|
334
|
+
promptEventIds: [...(current.promptEventIds ?? []), prompt.eventId],
|
|
335
|
+
prompts: [...(current.prompts ?? []), prompt],
|
|
336
|
+
});
|
|
337
|
+
}
|
|
265
338
|
function startFollower(community, channel) {
|
|
266
339
|
if (stopped)
|
|
267
340
|
return;
|
|
@@ -286,28 +359,32 @@ export function buzzBridge(options) {
|
|
|
286
359
|
if (followers.get(conversation) !== controller)
|
|
287
360
|
return;
|
|
288
361
|
const current = sessions.get(community, channel);
|
|
362
|
+
// The follower began from a copy; who asked and what was posted are the store's.
|
|
363
|
+
const { prompts: _stale, asker: _staleAsker, ...fresh } = state;
|
|
364
|
+
const kept = {
|
|
365
|
+
...(current?.asker ? { asker: current.asker } : {}),
|
|
366
|
+
...(current?.prompts?.length ? { prompts: current.prompts } : {}),
|
|
367
|
+
};
|
|
289
368
|
if (state.pendingInputRequests?.length) {
|
|
290
|
-
sessions.set(community, channel, { ...
|
|
369
|
+
sessions.set(community, channel, { ...fresh, ...kept, promptEventIds: current?.promptEventIds });
|
|
291
370
|
return;
|
|
292
371
|
}
|
|
293
372
|
if (current?.promptEventIds?.length)
|
|
294
373
|
relay.unwatchReplies(current.promptEventIds);
|
|
295
|
-
sessions.set(community, channel,
|
|
374
|
+
sessions.set(community, channel, { ...fresh, ...kept });
|
|
375
|
+
// Nothing is waiting on a person any more. A question the bridge did not see answered
|
|
376
|
+
// was answered elsewhere if the turn resumed, and is no longer waited on if it ended.
|
|
377
|
+
settleOpenPrompts(relay, community, channel, state.resumeInFlight ? "answered" : "withdrawn");
|
|
296
378
|
}),
|
|
297
379
|
onInputRequested: (requests) => serialize(community, channel, async () => {
|
|
298
380
|
if (followers.get(conversation) !== controller)
|
|
299
381
|
return;
|
|
300
|
-
const
|
|
382
|
+
const asker = sessions.get(community, channel)?.asker;
|
|
383
|
+
const promptId = relay.reply(channel, formatInputRequests(requests, { carry, asker, askerOnly }));
|
|
301
384
|
if (!promptId)
|
|
302
385
|
throw new Error("Buzz relay is not connected");
|
|
303
386
|
relay.watchReplies([promptId]);
|
|
304
|
-
|
|
305
|
-
if (current) {
|
|
306
|
-
sessions.set(community, channel, {
|
|
307
|
-
...current,
|
|
308
|
-
promptEventIds: [...(current.promptEventIds ?? []), promptId],
|
|
309
|
-
});
|
|
310
|
-
}
|
|
387
|
+
rememberPrompt(community, channel, { eventId: promptId, requests, ...(asker ? { asker } : {}) });
|
|
311
388
|
log(`posted another input request in ${channel.slice(0, 8)}`);
|
|
312
389
|
}),
|
|
313
390
|
onMessage: (message, cards) => serialize(community, channel, async () => {
|
|
@@ -364,7 +441,8 @@ export function buzzBridge(options) {
|
|
|
364
441
|
return;
|
|
365
442
|
if (current.promptEventIds?.length)
|
|
366
443
|
relay.unwatchReplies(current.promptEventIds);
|
|
367
|
-
|
|
444
|
+
settleOpenPrompts(relay, community, channel, "answered");
|
|
445
|
+
const { pendingInputRequests: _p, resumeInFlight: _r, promptEventIds: _e, prompts: _q, ...cleared } = sessions.get(community, channel) ?? current;
|
|
368
446
|
sessions.set(community, channel, cleared);
|
|
369
447
|
relay.reply(channel, resumeStalledReply(agentWorkTimeoutMs));
|
|
370
448
|
log(`gave up on the resumed turn in ${channel.slice(0, 8)}: no progress past index ${retry.index}`);
|
|
@@ -430,6 +508,14 @@ export function buzzBridge(options) {
|
|
|
430
508
|
if (pending?.pendingInputRequests?.length) {
|
|
431
509
|
let responses;
|
|
432
510
|
const carried = readHiddenAnswers(text);
|
|
511
|
+
if (askerOnly && pending.asker && !isAsker(pending.asker, event.pubkey, speaker.user.id)) {
|
|
512
|
+
// Someone else's question. A remark in its thread is people talking, not an answer:
|
|
513
|
+
// only a press, or a message that names the agent, is told whose answer it waits for.
|
|
514
|
+
if (carried.length || addressed)
|
|
515
|
+
relay.reply(channel, notTheAskerReply(pending.asker), event);
|
|
516
|
+
log(`ignored an answer in ${channel.slice(0, 8)} from ${speaker.user.email}: the question is for someone else`);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
433
519
|
if (carried.length) {
|
|
434
520
|
// A press in the plugin. It counts only as a direct reply to a prompt this bridge
|
|
435
521
|
// posted, so a message quoting someone else's question cannot answer this one.
|
|
@@ -466,6 +552,7 @@ export function buzzBridge(options) {
|
|
|
466
552
|
});
|
|
467
553
|
await respondToPendingConversation(session, responses);
|
|
468
554
|
log(`submitted ${responses.length} input response(s) in ${channel.slice(0, 8)}`);
|
|
555
|
+
const settlements = settlementsFor(responses, pending.pendingInputRequests, speaker.user.displayName?.trim() || undefined);
|
|
469
556
|
/**
|
|
470
557
|
* The answer is in, so this conversation is no longer waiting on a
|
|
471
558
|
* person. It is waiting on the agent, and that is a thing the bridge
|
|
@@ -487,6 +574,7 @@ export function buzzBridge(options) {
|
|
|
487
574
|
const { pendingInputRequests: _resolved, ...rest } = answered;
|
|
488
575
|
sessions.set(from, channel, remaining.length ? { ...rest, pendingInputRequests: remaining } : { ...rest, resumeInFlight: true });
|
|
489
576
|
}
|
|
577
|
+
settlePrompts(relay, from, channel, settlements);
|
|
490
578
|
return;
|
|
491
579
|
}
|
|
492
580
|
if (pending?.resumeInFlight) {
|
|
@@ -503,18 +591,19 @@ export function buzzBridge(options) {
|
|
|
503
591
|
const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
|
|
504
592
|
const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log, agentSilenceTimeoutMs, agentWorkTimeoutMs);
|
|
505
593
|
if (result.status === "waiting" && result.inputRequests.length > 0) {
|
|
594
|
+
const name = speaker.user.displayName?.trim();
|
|
595
|
+
const asker = { key: event.pubkey, ...(speaker.user.id ? { userId: speaker.user.id } : {}), ...(name ? { name } : {}) };
|
|
506
596
|
sessions.set(from, channel, {
|
|
507
597
|
id: result.sessionId,
|
|
508
598
|
streamIndex: result.streamIndex,
|
|
509
599
|
pendingInputRequests: result.inputRequests,
|
|
510
600
|
speakerPublicKey: event.pubkey,
|
|
601
|
+
asker,
|
|
511
602
|
});
|
|
512
|
-
const promptId = relay.reply(channel, formatInputRequests(result.inputRequests, { carry
|
|
603
|
+
const promptId = relay.reply(channel, formatInputRequests(result.inputRequests, { carry, asker, askerOnly }), event);
|
|
513
604
|
if (promptId) {
|
|
514
605
|
relay.watchReplies([promptId]);
|
|
515
|
-
|
|
516
|
-
if (current)
|
|
517
|
-
sessions.set(from, channel, { ...current, promptEventIds: [promptId] });
|
|
606
|
+
rememberPrompt(from, channel, { eventId: promptId, requests: result.inputRequests, asker });
|
|
518
607
|
}
|
|
519
608
|
log(`posted ${result.inputRequests.length} input request(s) in ${channel.slice(0, 8)}`);
|
|
520
609
|
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,7 +1,18 @@
|
|
|
1
1
|
import { type ClientSession, type InputRequest, type InputResponse } from "eve/client";
|
|
2
|
-
import { type AskAnswer, 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
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
|
+
}
|
|
5
16
|
/**
|
|
6
17
|
* Render pending Eve input in a Buzz channel.
|
|
7
18
|
*
|
|
@@ -11,10 +22,34 @@ import { type TurnFailure } from "./turn.js";
|
|
|
11
22
|
* (`<!-- kyb:ask v1 … -->`), so the Kybernesis plugin can draw it the way
|
|
12
23
|
* Studio does and answer it with a press. Only a posted prompt carries them:
|
|
13
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.
|
|
14
29
|
*/
|
|
15
|
-
export declare function formatInputRequests(requests: readonly InputRequest[], options?:
|
|
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: {
|
|
16
42
|
carry?: boolean;
|
|
17
|
-
|
|
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>;
|
|
18
53
|
/**
|
|
19
54
|
* Answers a client pressed, checked against what is actually open.
|
|
20
55
|
*
|
package/dist/hitl.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseInputResponses, resolveTextToResponses, } from "eve/client";
|
|
2
|
-
import { askFrom, askText, cardsOf, hiddenAsk, newStepState, readStep } from "@kybernesis/cards";
|
|
2
|
+
import { askFrom, askText, cardsOf, hiddenAsk, hiddenSettled, newStepState, readStep, settledLine, settleWith, } from "@kybernesis/cards";
|
|
3
3
|
import { failureFromEvent, silentTurnReply } from "./turn.js";
|
|
4
4
|
/**
|
|
5
5
|
* Render pending Eve input in a Buzz channel.
|
|
@@ -10,34 +10,113 @@ import { failureFromEvent, silentTurnReply } from "./turn.js";
|
|
|
10
10
|
* (`<!-- kyb:ask v1 … -->`), so the Kybernesis plugin can draw it the way
|
|
11
11
|
* Studio does and answer it with a press. Only a posted prompt carries them:
|
|
12
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.
|
|
13
17
|
*/
|
|
14
18
|
export function formatInputRequests(requests, options = {}) {
|
|
15
|
-
const
|
|
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];
|
|
16
27
|
const text = asks.map((ask, requestIndex) => {
|
|
17
28
|
const request = requests[requestIndex];
|
|
18
29
|
const prompt = askText(ask);
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
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)];
|
|
22
35
|
if (request.options?.length) {
|
|
23
36
|
lines.push("", ...request.options.map((option, optionIndex) => {
|
|
24
37
|
const description = option.description ? ` — ${option.description}` : "";
|
|
25
38
|
return `${optionIndex + 1}. ${option.label} [${option.id}]${description}`;
|
|
26
39
|
}));
|
|
27
40
|
}
|
|
41
|
+
const who = only && person?.name ? `${person.name}, reply` : "Reply";
|
|
28
42
|
if (request.allowFreeform === true || !request.options?.length) {
|
|
29
|
-
lines.push("",
|
|
43
|
+
lines.push("", `${who} to this message with an option number, option ID, exact label, or your own answer.`);
|
|
30
44
|
}
|
|
31
45
|
else {
|
|
32
|
-
lines.push("",
|
|
46
|
+
lines.push("", `${who} to this message with an option number, option ID, or exact label.`);
|
|
33
47
|
}
|
|
34
48
|
return lines.join("\n");
|
|
35
49
|
}).join("\n\n");
|
|
36
50
|
if (!options.carry)
|
|
37
51
|
return text;
|
|
38
|
-
const hidden = asks
|
|
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);
|
|
39
58
|
return hidden.length ? `${text}\n\n${hidden.join("\n")}` : text;
|
|
40
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
|
+
}
|
|
41
120
|
/**
|
|
42
121
|
* Answers a client pressed, checked against what is actually open.
|
|
43
122
|
*
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Card } from "@kybernesis/cards";
|
|
2
|
+
import type { AgentKey } from "./keys.js";
|
|
3
|
+
import { type UploadedMedia } from "./profile.js";
|
|
4
|
+
export interface PictureMessage {
|
|
5
|
+
content: string;
|
|
6
|
+
tags: string[][];
|
|
7
|
+
}
|
|
8
|
+
export interface PictureOptions {
|
|
9
|
+
/** The agent's base URL, where the screenshot is served. */
|
|
10
|
+
agentUrl: string;
|
|
11
|
+
/** The asker's headers for the agent: the screenshot is theirs to read. */
|
|
12
|
+
headers: () => Promise<Record<string, string>>;
|
|
13
|
+
/** The community the message goes to: its media store holds the picture. */
|
|
14
|
+
relay: string;
|
|
15
|
+
key: AgentKey;
|
|
16
|
+
fetchImpl?: typeof fetch;
|
|
17
|
+
upload?: (relay: string, key: AgentKey, bytes: Uint8Array, mediaType: string) => Promise<UploadedMedia>;
|
|
18
|
+
}
|
|
19
|
+
/** Whether this card is a screenshot the bridge can carry as a picture. */
|
|
20
|
+
export declare function isAgentScreenshot(card: Card): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The screenshot card as a Buzz picture message, or null when it is not one
|
|
23
|
+
* the bridge carries. Throws when the picture cannot be fetched or stored; the
|
|
24
|
+
* caller then posts the card as it was.
|
|
25
|
+
*/
|
|
26
|
+
export declare function pictureMessage(card: Card, options: PictureOptions): Promise<PictureMessage | null>;
|
package/dist/pictures.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { uploadMedia } from "./profile.js";
|
|
2
|
+
/**
|
|
3
|
+
* A screenshot the agent showed someone, as a picture in the conversation.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* `show_screen` keeps the image on the agent's machine and names it by a path
|
|
7
|
+
* on the agent (`/eve/v1/kyb/screens/<id>.png`) that only the person it was
|
|
8
|
+
* taken for can read. KYBER Studio fetches it with that person's token. Buzz
|
|
9
|
+
* cannot: the cards plugin has no token, so it drew "This picture is on the
|
|
10
|
+
* agent and can't be shown here", and Buzz without the plugin showed only the
|
|
11
|
+
* caption.
|
|
12
|
+
*
|
|
13
|
+
* So the bridge fetches it, as the person who asked, and puts it in the
|
|
14
|
+
* community's own media store, which serves only members of that community.
|
|
15
|
+
* The message is an ordinary Buzz picture message (the words, a Markdown image
|
|
16
|
+
* and its `imeta` tag, as Buzz's own composer writes them), so every Buzz app
|
|
17
|
+
* draws it, with or without the plugin. Asking for a screenshot in a channel
|
|
18
|
+
* shares it with that channel, the same as an artifact posted there.
|
|
19
|
+
*/
|
|
20
|
+
/** The only address a screenshot card may name for the bridge to fetch: one on the agent, by id. */
|
|
21
|
+
const SCREEN_PATH = /^\/eve\/v1\/kyb\/screens\/[0-9a-f]{32}(?:\.png)?$/;
|
|
22
|
+
/** Larger than any screen; the community's own limit for images is higher still. */
|
|
23
|
+
const MAX_BYTES = 20 * 1024 * 1024;
|
|
24
|
+
/** Whether this card is a screenshot the bridge can carry as a picture. */
|
|
25
|
+
export function isAgentScreenshot(card) {
|
|
26
|
+
if (card.card !== "screenshot" || !card.data || typeof card.data !== "object")
|
|
27
|
+
return false;
|
|
28
|
+
const image = card.data.image;
|
|
29
|
+
return typeof image === "string" && SCREEN_PATH.test(image);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The screenshot card as a Buzz picture message, or null when it is not one
|
|
33
|
+
* the bridge carries. Throws when the picture cannot be fetched or stored; the
|
|
34
|
+
* caller then posts the card as it was.
|
|
35
|
+
*/
|
|
36
|
+
export async function pictureMessage(card, options) {
|
|
37
|
+
if (!isAgentScreenshot(card))
|
|
38
|
+
return null;
|
|
39
|
+
const data = card.data;
|
|
40
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
41
|
+
const upload = options.upload ?? uploadMedia;
|
|
42
|
+
const response = await fetchImpl(`${options.agentUrl.replace(/\/$/, "")}${data.image}`, {
|
|
43
|
+
headers: await options.headers(),
|
|
44
|
+
signal: AbortSignal.timeout(15_000),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new Error(`${response.status} fetching the screenshot from the agent`);
|
|
48
|
+
if (Number(response.headers.get("content-length") ?? 0) > MAX_BYTES)
|
|
49
|
+
throw new Error("the screenshot is too large");
|
|
50
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
51
|
+
const size = pngSize(bytes);
|
|
52
|
+
if (!size || bytes.byteLength > MAX_BYTES)
|
|
53
|
+
throw new Error("the agent did not answer with a PNG");
|
|
54
|
+
const stored = await upload(options.relay, options.key, bytes, "image/png");
|
|
55
|
+
const caption = typeof data.caption === "string" ? oneLine(data.caption) : "";
|
|
56
|
+
const site = typeof data.site === "string" ? oneLine(data.site) : "";
|
|
57
|
+
const words = caption && site ? `${caption} (${site})` : caption || site || "A screenshot from the agent's computer.";
|
|
58
|
+
return {
|
|
59
|
+
content: `${words}\n\n`,
|
|
60
|
+
tags: [
|
|
61
|
+
[
|
|
62
|
+
"imeta",
|
|
63
|
+
`url ${stored.url}`,
|
|
64
|
+
"m image/png",
|
|
65
|
+
`x ${stored.sha256}`,
|
|
66
|
+
`size ${stored.size}`,
|
|
67
|
+
`dim ${size.width}x${size.height}`,
|
|
68
|
+
"filename screenshot.png",
|
|
69
|
+
],
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Width and height from a PNG's header, when the bytes are one. */
|
|
74
|
+
function pngSize(bytes) {
|
|
75
|
+
if (bytes.length < 24 || bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47)
|
|
76
|
+
return null;
|
|
77
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
78
|
+
const width = view.getUint32(16);
|
|
79
|
+
const height = view.getUint32(20);
|
|
80
|
+
return width > 0 && height > 0 ? { width, height } : null;
|
|
81
|
+
}
|
|
82
|
+
function oneLine(text) {
|
|
83
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
84
|
+
}
|
|
85
|
+
/** A Markdown image label, escaped the way Buzz escapes an attachment's name. */
|
|
86
|
+
function markdownLabel(text) {
|
|
87
|
+
return Array.from(text, (char) => (char.charCodeAt(0) < 32 ? " " : char))
|
|
88
|
+
.join("")
|
|
89
|
+
.replace(/[\\`*_{}[\]()!<>&]/g, "\\$&");
|
|
90
|
+
}
|
package/dist/profile.d.ts
CHANGED
|
@@ -46,6 +46,14 @@ export declare function copy(from: string, to: readonly string[], key: AgentKey)
|
|
|
46
46
|
* up, and nobody should need a blob token to give their agent a face.
|
|
47
47
|
*/
|
|
48
48
|
export declare function uploadImage(relay: string, key: AgentKey, bytes: Uint8Array, mediaType: string): Promise<string>;
|
|
49
|
+
/** What a community's media store holds after an upload: what a message's `imeta` tag names. */
|
|
50
|
+
export type UploadedMedia = {
|
|
51
|
+
url: string;
|
|
52
|
+
sha256: string;
|
|
53
|
+
size: number;
|
|
54
|
+
};
|
|
55
|
+
/** {@link uploadImage}, answering with the hash and size of what was stored too. */
|
|
56
|
+
export declare function uploadMedia(relay: string, key: AgentKey, bytes: Uint8Array, mediaType: string): Promise<UploadedMedia>;
|
|
49
57
|
/**
|
|
50
58
|
* Remove everything from an image that is not the image.
|
|
51
59
|
*
|
package/dist/profile.js
CHANGED
|
@@ -147,6 +147,10 @@ export async function copy(from, to, key) {
|
|
|
147
147
|
* up, and nobody should need a blob token to give their agent a face.
|
|
148
148
|
*/
|
|
149
149
|
export async function uploadImage(relay, key, bytes, mediaType) {
|
|
150
|
+
return (await uploadMedia(relay, key, bytes, mediaType)).url;
|
|
151
|
+
}
|
|
152
|
+
/** {@link uploadImage}, answering with the hash and size of what was stored too. */
|
|
153
|
+
export async function uploadMedia(relay, key, bytes, mediaType) {
|
|
150
154
|
const { createHash } = await import("node:crypto");
|
|
151
155
|
const base = relay.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/$/, "");
|
|
152
156
|
// Stripped BEFORE hashing: the hash has to be of what is actually sent, and a
|
|
@@ -183,10 +187,10 @@ export async function uploadImage(relay, key, bytes, mediaType) {
|
|
|
183
187
|
body: body,
|
|
184
188
|
});
|
|
185
189
|
if (response.ok) {
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return
|
|
190
|
+
const answer = (await response.json().catch(() => ({})));
|
|
191
|
+
// Absolute either way: a message names its picture by full address, and a server may answer with a path.
|
|
192
|
+
const url = typeof answer.url === "string" ? new URL(answer.url, `${base}/`).toString() : `${base}/${sha256}`;
|
|
193
|
+
return { url, sha256, size: body.byteLength };
|
|
190
194
|
}
|
|
191
195
|
last = `${response.status} ${(await response.text().catch(() => "")).slice(0, 120)}`;
|
|
192
196
|
}
|
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. */
|
|
@@ -103,7 +105,8 @@ export declare class BuzzRelay {
|
|
|
103
105
|
private discovery;
|
|
104
106
|
/** Since when a newly found DM is read back, so a message sent before it was found is not lost. */
|
|
105
107
|
private discoveredAt;
|
|
106
|
-
|
|
108
|
+
/** The community's address, as configured. */
|
|
109
|
+
readonly url: string;
|
|
107
110
|
private readonly rest;
|
|
108
111
|
private readonly key;
|
|
109
112
|
private readonly pollMs;
|
|
@@ -162,7 +165,15 @@ export declare class BuzzRelay {
|
|
|
162
165
|
/** `mention: false` threads under `replyTo` without notifying its author again, as a follow-up to a reply does. */
|
|
163
166
|
reply(channel: string, text: string, replyTo?: NostrEvent, options?: {
|
|
164
167
|
mention?: boolean;
|
|
168
|
+
tags?: readonly string[][];
|
|
165
169
|
}): string | null;
|
|
170
|
+
/**
|
|
171
|
+
* Replace the words of a message this agent posted. Every client shows the
|
|
172
|
+
* latest edit by the author, and only the author's edits count, so nobody
|
|
173
|
+
* else can make a message of the agent's say something else. No mention
|
|
174
|
+
* tag: an edit is not news, so nobody is notified again.
|
|
175
|
+
*/
|
|
176
|
+
edit(channel: string, eventId: string, text: string): string | null;
|
|
166
177
|
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
167
178
|
private httpAuth;
|
|
168
179
|
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. */
|
|
@@ -102,6 +104,7 @@ export class BuzzRelay {
|
|
|
102
104
|
discovery = null;
|
|
103
105
|
/** Since when a newly found DM is read back, so a message sent before it was found is not lost. */
|
|
104
106
|
discoveredAt = null;
|
|
107
|
+
/** The community's address, as configured. */
|
|
105
108
|
url;
|
|
106
109
|
rest;
|
|
107
110
|
key;
|
|
@@ -425,7 +428,7 @@ export class BuzzRelay {
|
|
|
425
428
|
if (this.authenticated && this.send(["EVENT", signed]))
|
|
426
429
|
return signed.id;
|
|
427
430
|
// Held only between connections of a running relay; one never started or closed sends nothing.
|
|
428
|
-
if (this.closed || !this.socket ||
|
|
431
|
+
if (this.closed || !this.socket || ![KIND_MESSAGE, KIND_REACTION, KIND_EDIT].includes(event.kind))
|
|
429
432
|
return null;
|
|
430
433
|
if (this.outbox.length >= MAX_OUTBOX)
|
|
431
434
|
this.outbox.shift();
|
|
@@ -473,8 +476,20 @@ export class BuzzRelay {
|
|
|
473
476
|
if (options.mention !== false)
|
|
474
477
|
tags.push(["p", replyTo.pubkey]);
|
|
475
478
|
}
|
|
479
|
+
// Attachments (`imeta`), which the words name.
|
|
480
|
+
for (const tag of options.tags ?? [])
|
|
481
|
+
tags.push([...tag]);
|
|
476
482
|
return this.publish({ kind: KIND_MESSAGE, tags, content: text });
|
|
477
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* Replace the words of a message this agent posted. Every client shows the
|
|
486
|
+
* latest edit by the author, and only the author's edits count, so nobody
|
|
487
|
+
* else can make a message of the agent's say something else. No mention
|
|
488
|
+
* tag: an edit is not news, so nobody is notified again.
|
|
489
|
+
*/
|
|
490
|
+
edit(channel, eventId, text) {
|
|
491
|
+
return this.publish({ kind: KIND_EDIT, tags: [["h", channel], ["e", eventId]], content: text });
|
|
492
|
+
}
|
|
478
493
|
/** Sign one HTTP request as this agent (NIP-98), binding the signature to the body. */
|
|
479
494
|
httpAuth(url, body) {
|
|
480
495
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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"
|