@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 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. Anyone can answer by replying with a number, an option or their own words. Since 0.14 the
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, { ...state, promptEventIds: current?.promptEventIds });
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, state);
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 promptId = relay.reply(channel, formatInputRequests(requests, { carry: options.cards !== false }));
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
- const current = sessions.get(community, channel);
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
- const { pendingInputRequests: _p, resumeInFlight: _r, promptEventIds: _e, ...cleared } = current;
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: options.cards !== false }), event);
603
+ const promptId = relay.reply(channel, formatInputRequests(result.inputRequests, { carry, asker, askerOnly }), event);
513
604
  if (promptId) {
514
605
  relay.watchReplies([promptId]);
515
- const current = sessions.get(from, channel);
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
- }): string;
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 asks = requests.map((request) => askFrom(request));
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 lines = [
20
- requests.length > 1 ? `Question ${requestIndex + 1}: ${prompt}` : prompt,
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("", "Reply to this message with an option number, option ID, exact label, or your own answer.");
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("", "Reply to this message with an option number, option ID, or exact label.");
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.map((ask) => hiddenAsk(ask)).filter((h) => h !== null);
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>;
@@ -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![${markdownLabel(caption || "Screenshot")}](<${stored.url}>)`,
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 body = (await response.json().catch(() => ({})));
187
- if (typeof body.url === "string")
188
- return body.url;
189
- return `${base}/${sha256}`;
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
- private readonly url;
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 || (event.kind !== KIND_MESSAGE && event.kind !== KIND_REACTION))
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({
@@ -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.14.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.5.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"