@kybernesis/buzz 0.15.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,6 +95,13 @@ 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
package/dist/bridge.js CHANGED
@@ -1,6 +1,7 @@
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";
@@ -78,6 +79,27 @@ export function buzzBridge(options) {
78
79
  return card;
79
80
  }
80
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
+ };
81
103
  /**
82
104
  * A turn's cards, one message each, threaded where the reply went.
83
105
  *
@@ -90,6 +112,12 @@ export function buzzBridge(options) {
90
112
  return;
91
113
  let posted = 0;
92
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
+ }
93
121
  const content = cardMessage(card);
94
122
  if (content && relay.reply(channel, content, replyTo, { mention: false }))
95
123
  posted += 1;
@@ -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
@@ -105,7 +105,8 @@ export declare class BuzzRelay {
105
105
  private discovery;
106
106
  /** Since when a newly found DM is read back, so a message sent before it was found is not lost. */
107
107
  private discoveredAt;
108
- private readonly url;
108
+ /** The community's address, as configured. */
109
+ readonly url: string;
109
110
  private readonly rest;
110
111
  private readonly key;
111
112
  private readonly pollMs;
@@ -164,6 +165,7 @@ export declare class BuzzRelay {
164
165
  /** `mention: false` threads under `replyTo` without notifying its author again, as a follow-up to a reply does. */
165
166
  reply(channel: string, text: string, replyTo?: NostrEvent, options?: {
166
167
  mention?: boolean;
168
+ tags?: readonly string[][];
167
169
  }): string | null;
168
170
  /**
169
171
  * Replace the words of a message this agent posted. Every client shows the
package/dist/relay.js CHANGED
@@ -104,6 +104,7 @@ export class BuzzRelay {
104
104
  discovery = null;
105
105
  /** Since when a newly found DM is read back, so a message sent before it was found is not lost. */
106
106
  discoveredAt = null;
107
+ /** The community's address, as configured. */
107
108
  url;
108
109
  rest;
109
110
  key;
@@ -475,6 +476,9 @@ export class BuzzRelay {
475
476
  if (options.mention !== false)
476
477
  tags.push(["p", replyTo.pubkey]);
477
478
  }
479
+ // Attachments (`imeta`), which the words name.
480
+ for (const tag of options.tags ?? [])
481
+ tags.push([...tag]);
478
482
  return this.publish({ kind: KIND_MESSAGE, tags, content: text });
479
483
  }
480
484
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.15.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",