@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 +7 -0
- package/dist/bridge.js +28 -0
- 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 +3 -1
- package/dist/relay.js +4 -0
- package/package.json +1 -1
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>;
|
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
|
@@ -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
|
-
|
|
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.
|
|
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",
|